2.18.0: New Invitation flow, refactored Database Webhooks, new ShadCN UI Components (#384)
* Streamlined invitations flow * Removed web hooks in favor of handling logic directly in server actions * Added new Shadcn UI Components
This commit is contained in:
committed by
GitHub
parent
195cf41680
commit
2e20d3e76f
369
apps/dev-tool/app/components/components/button-group-story.tsx
Normal file
369
apps/dev-tool/app/components/components/button-group-story.tsx
Normal 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;
|
||||||
@@ -19,7 +19,7 @@ export function DocsContent({ selectedComponent }: DocsContentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<Suspense fallback={<LoadingFallback />}>
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<component.component />
|
<component.component />
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@kit/ui/card';
|
} from '@kit/ui/card';
|
||||||
import {
|
import {
|
||||||
|
EmptyMedia,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
EmptyStateButton,
|
EmptyStateButton,
|
||||||
EmptyStateHeading,
|
EmptyStateHeading,
|
||||||
@@ -290,7 +291,9 @@ export function EmptyStateStory() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||||
<EmptyState className="min-h-[200px]">
|
<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>
|
<EmptyStateHeading>No products</EmptyStateHeading>
|
||||||
<EmptyStateText>
|
<EmptyStateText>
|
||||||
Add your first product to start selling.
|
Add your first product to start selling.
|
||||||
@@ -299,7 +302,9 @@ export function EmptyStateStory() {
|
|||||||
</EmptyState>
|
</EmptyState>
|
||||||
|
|
||||||
<EmptyState className="min-h-[200px]">
|
<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>
|
<EmptyStateHeading>No documents</EmptyStateHeading>
|
||||||
<EmptyStateText>
|
<EmptyStateText>
|
||||||
Upload or create your first document.
|
Upload or create your first document.
|
||||||
|
|||||||
468
apps/dev-tool/app/components/components/field-story.tsx
Normal file
468
apps/dev-tool/app/components/components/field-story.tsx
Normal 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;
|
||||||
394
apps/dev-tool/app/components/components/input-group-story.tsx
Normal file
394
apps/dev-tool/app/components/components/input-group-story.tsx
Normal 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;
|
||||||
412
apps/dev-tool/app/components/components/item-story.tsx
Normal file
412
apps/dev-tool/app/components/components/item-story.tsx
Normal 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;
|
||||||
277
apps/dev-tool/app/components/components/kbd-story.tsx
Normal file
277
apps/dev-tool/app/components/components/kbd-story.tsx
Normal 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;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -10,7 +11,11 @@ import {
|
|||||||
import { Label } from '@kit/ui/label';
|
import { Label } from '@kit/ui/label';
|
||||||
import { Spinner } from '@kit/ui/spinner';
|
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 { ComponentStoryLayout } from './story-layout';
|
||||||
import { SimpleStorySelect } from './story-select';
|
import { SimpleStorySelect } from './story-select';
|
||||||
|
|
||||||
@@ -44,7 +49,9 @@ export function SpinnerStory() {
|
|||||||
{ className: undefined },
|
{ className: undefined },
|
||||||
);
|
);
|
||||||
|
|
||||||
return `<Spinner${propsString} />`;
|
return formatCodeBlock(`<Spinner${propsString} />`, [
|
||||||
|
"import { Spinner } from '@kit/ui/spinner';",
|
||||||
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderPreview = () => (
|
const renderPreview = () => (
|
||||||
@@ -105,10 +112,10 @@ export function SpinnerStory() {
|
|||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="text-sm font-semibold">Button Loading</h4>
|
<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">
|
<Button className="gap-2" disabled>
|
||||||
<Spinner className="mr-2 h-4 w-4" />
|
<Spinner className="h-4 w-4" />
|
||||||
Loading...
|
Loading...
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -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(
|
const CardStory = dynamic(
|
||||||
() =>
|
() =>
|
||||||
import('../components/card-story').then((mod) => ({
|
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(
|
const HeadingStory = dynamic(
|
||||||
() =>
|
() =>
|
||||||
import('../components/heading-story').then((mod) => ({
|
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(
|
const InputOTPStory = dynamic(
|
||||||
() =>
|
() =>
|
||||||
import('../components/input-otp-story').then((mod) => ({
|
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(
|
const RadioGroupStory = dynamic(
|
||||||
() =>
|
() =>
|
||||||
import('../components/radio-group-story').then((mod) => ({
|
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
|
// Component type definition
|
||||||
export interface ComponentInfo {
|
export interface ComponentInfo {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -405,6 +455,34 @@ export const COMPONENTS_REGISTRY: ComponentInfo[] = [
|
|||||||
icon: Type,
|
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',
|
id: 'card-button',
|
||||||
name: 'Card Button',
|
name: 'Card Button',
|
||||||
@@ -806,6 +884,20 @@ export const COMPONENTS_REGISTRY: ComponentInfo[] = [
|
|||||||
icon: HeadingIcon,
|
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
|
// Interaction Components
|
||||||
{
|
{
|
||||||
id: 'button',
|
id: 'button',
|
||||||
@@ -820,6 +912,20 @@ export const COMPONENTS_REGISTRY: ComponentInfo[] = [
|
|||||||
icon: MousePointer,
|
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
|
// Layout Components
|
||||||
{
|
{
|
||||||
id: 'card',
|
id: 'card',
|
||||||
@@ -834,6 +940,20 @@ export const COMPONENTS_REGISTRY: ComponentInfo[] = [
|
|||||||
icon: Layout,
|
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',
|
id: 'badge',
|
||||||
name: 'Badge',
|
name: 'Badge',
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ async function ComponentDocsPage(props: ComponentDocsPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background flex h-screen">
|
<div className="bg-background flex h-screen overflow-x-hidden">
|
||||||
<DocsSidebar selectedComponent={component} selectedCategory={category} />
|
<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} />
|
<DocsHeader selectedComponent={component} />
|
||||||
<DocsContent selectedComponent={component} />
|
<DocsContent selectedComponent={component} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ const nextConfig: NextConfig = {
|
|||||||
experimental: {
|
experimental: {
|
||||||
reactCompiler: true,
|
reactCompiler: true,
|
||||||
},
|
},
|
||||||
|
devIndicators: {
|
||||||
|
position: 'bottom-right',
|
||||||
|
},
|
||||||
logging: {
|
logging: {
|
||||||
fetches: {
|
fetches: {
|
||||||
fullUrl: true,
|
fullUrl: true,
|
||||||
|
|||||||
@@ -70,8 +70,10 @@ test.describe('Admin', () => {
|
|||||||
// use the email as the filter text
|
// use the email as the filter text
|
||||||
const filterText = testUserEmail;
|
const filterText = testUserEmail;
|
||||||
|
|
||||||
await filterAccounts(page, filterText);
|
await expect(async () => {
|
||||||
await selectAccount(page, filterText);
|
await filterAccounts(page, filterText);
|
||||||
|
await selectAccount(page, filterText);
|
||||||
|
}).toPass();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ban user flow', async ({ page }) => {
|
test('ban user flow', async ({ page }) => {
|
||||||
@@ -224,8 +226,10 @@ test.describe('Admin', () => {
|
|||||||
|
|
||||||
await page.goto(`/admin/accounts`);
|
await page.goto(`/admin/accounts`);
|
||||||
|
|
||||||
await filterAccounts(page, filterText);
|
await expect(async () => {
|
||||||
await selectAccount(page, filterText);
|
await filterAccounts(page, filterText);
|
||||||
|
await selectAccount(page, filterText);
|
||||||
|
}).toPass();
|
||||||
|
|
||||||
await page.getByTestId('admin-impersonate-button').click();
|
await page.getByTestId('admin-impersonate-button').click();
|
||||||
|
|
||||||
@@ -283,8 +287,10 @@ test.describe('Team Account Management', () => {
|
|||||||
|
|
||||||
await page.goto(`/admin/accounts`);
|
await page.goto(`/admin/accounts`);
|
||||||
|
|
||||||
await filterAccounts(page, teamName);
|
await expect(async () => {
|
||||||
await selectAccount(page, teamName);
|
await filterAccounts(page, teamName);
|
||||||
|
await selectAccount(page, teamName);
|
||||||
|
}).toPass();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('delete team account flow', async ({ page }) => {
|
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) {
|
async function selectAccount(page: Page, email: string) {
|
||||||
await expect(async () => {
|
const link = page
|
||||||
const link = page
|
.locator('tr', { hasText: email.split('@')[0] })
|
||||||
.locator('tr', { hasText: email.split('@')[0] })
|
.locator('a');
|
||||||
.locator('a');
|
|
||||||
|
|
||||||
await expect(link).toBeVisible();
|
await expect(link).toBeVisible();
|
||||||
|
|
||||||
await link.click();
|
await link.click();
|
||||||
|
|
||||||
await page.waitForURL(/\/admin\/accounts\/[^\/]+/);
|
await page.waitForURL(/\/admin\/accounts\/[^\/]+/);
|
||||||
}).toPass();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,15 +45,7 @@ test.describe('Auth flow', () => {
|
|||||||
password: 'password',
|
password: 'password',
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.waitForURL('**/home', {
|
await page.waitForURL('**/home');
|
||||||
timeout: 5_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(page.url()).toContain('/home');
|
|
||||||
|
|
||||||
await auth.signOut();
|
|
||||||
|
|
||||||
expect(page.url()).toContain('/');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('will sign out using the dropdown', async ({ page }) => {
|
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');
|
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -29,9 +29,7 @@ test.describe('Password Reset Flow', () => {
|
|||||||
subject: 'Reset your password',
|
subject: 'Reset your password',
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.waitForURL('/update-password', {
|
await page.waitForURL('/update-password');
|
||||||
timeout: 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await auth.updatePassword(newPassword);
|
await auth.updatePassword(newPassword);
|
||||||
|
|
||||||
@@ -44,7 +42,7 @@ test.describe('Password Reset Flow', () => {
|
|||||||
await page.waitForURL('/home');
|
await page.waitForURL('/home');
|
||||||
}).toPass();
|
}).toPass();
|
||||||
|
|
||||||
await auth.signOut();
|
await page.context().clearCookies();
|
||||||
|
|
||||||
await page.waitForURL('/');
|
await page.waitForURL('/');
|
||||||
await page.goto('/auth/sign-in');
|
await page.goto('/auth/sign-in');
|
||||||
|
|||||||
@@ -120,16 +120,6 @@ test.describe('Full Invitation Flow', () => {
|
|||||||
|
|
||||||
await invitations.auth.visitConfirmEmailLink(firstEmail);
|
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}`);
|
console.log(`Accepting invitation as ${firstEmail}`);
|
||||||
|
|
||||||
await invitations.acceptInvitation();
|
await invitations.acceptInvitation();
|
||||||
|
|||||||
@@ -37,14 +37,6 @@ async function setupTeamWithMember(page: Page, memberRole = 'member') {
|
|||||||
// Sign up with the new member email and accept the invitation
|
// Sign up with the new member email and accept the invitation
|
||||||
await invitations.auth.visitConfirmEmailLink(memberEmail);
|
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.acceptInvitation();
|
||||||
|
|
||||||
await invitations.teamAccounts.openAccountsSelector();
|
await invitations.teamAccounts.openAccountsSelector();
|
||||||
@@ -248,9 +240,7 @@ test.describe('Team Ownership Transfer', () => {
|
|||||||
const memberRow = page.getByRole('row', { name: memberEmail });
|
const memberRow = page.getByRole('row', { name: memberEmail });
|
||||||
|
|
||||||
// Check for the primary owner badge on the member's row
|
// Check for the primary owner badge on the member's row
|
||||||
await expect(memberRow.locator('text=Primary Owner')).toBeVisible({
|
await expect(memberRow.locator('text=Primary Owner')).toBeVisible();
|
||||||
timeout: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// The original owner should no longer have the primary owner badge
|
// The original owner should no longer have the primary owner badge
|
||||||
const ownerRow = page.getByRole('row', { name: ownerEmail.split('@')[0] });
|
const ownerRow = page.getByRole('row', { name: ownerEmail.split('@')[0] });
|
||||||
|
|||||||
@@ -56,16 +56,6 @@ test.describe('Team Invitation with MFA Flow', () => {
|
|||||||
|
|
||||||
await auth.visitConfirmEmailLink('super-admin@makerkit.dev');
|
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
|
// Complete MFA verification
|
||||||
await expect(async () => {
|
await expect(async () => {
|
||||||
await auth.submitMFAVerification(AuthPageObject.MFA_KEY);
|
await auth.submitMFAVerification(AuthPageObject.MFA_KEY);
|
||||||
|
|||||||
@@ -65,7 +65,13 @@ async function DocumentationPage({ params }: DocumentationPageProps) {
|
|||||||
<section
|
<section
|
||||||
className={'flex flex-col gap-y-1 border-b border-dashed pb-4'}
|
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'}>
|
<h2 className={'text-secondary-foreground/80 text-lg'}>
|
||||||
{description}
|
{description}
|
||||||
|
|||||||
@@ -26,11 +26,7 @@ export const generateMetadata = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function SignInPage({ searchParams }: SignInPageProps) {
|
async function SignInPage({ searchParams }: SignInPageProps) {
|
||||||
const { invite_token: inviteToken, next } = await searchParams;
|
const { next } = await searchParams;
|
||||||
|
|
||||||
const signUpPath =
|
|
||||||
pathsConfig.auth.signUp +
|
|
||||||
(inviteToken ? `?invite_token=${inviteToken}` : '');
|
|
||||||
|
|
||||||
const paths = {
|
const paths = {
|
||||||
callback: pathsConfig.auth.callback,
|
callback: pathsConfig.auth.callback,
|
||||||
@@ -50,15 +46,11 @@ async function SignInPage({ searchParams }: SignInPageProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SignInMethodsContainer
|
<SignInMethodsContainer paths={paths} providers={authConfig.providers} />
|
||||||
inviteToken={inviteToken}
|
|
||||||
paths={paths}
|
|
||||||
providers={authConfig.providers}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className={'flex justify-center'}>
|
<div className={'flex justify-center'}>
|
||||||
<Button asChild variant={'link'} size={'sm'}>
|
<Button asChild variant={'link'} size={'sm'}>
|
||||||
<Link href={signUpPath} prefetch={true}>
|
<Link href={pathsConfig.auth.signUp} prefetch={true}>
|
||||||
<Trans i18nKey={'auth:doNotHaveAccountYet'} />
|
<Trans i18nKey={'auth:doNotHaveAccountYet'} />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -18,24 +18,12 @@ export const generateMetadata = async () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Props {
|
|
||||||
searchParams: Promise<{
|
|
||||||
invite_token?: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const paths = {
|
const paths = {
|
||||||
callback: pathsConfig.auth.callback,
|
callback: pathsConfig.auth.callback,
|
||||||
appHome: pathsConfig.app.home,
|
appHome: pathsConfig.app.home,
|
||||||
};
|
};
|
||||||
|
|
||||||
async function SignUpPage({ searchParams }: Props) {
|
async function SignUpPage() {
|
||||||
const inviteToken = (await searchParams).invite_token;
|
|
||||||
|
|
||||||
const signInPath =
|
|
||||||
pathsConfig.auth.signIn +
|
|
||||||
(inviteToken ? `?invite_token=${inviteToken}` : '');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={'flex flex-col items-center gap-1'}>
|
<div className={'flex flex-col items-center gap-1'}>
|
||||||
@@ -51,13 +39,12 @@ async function SignUpPage({ searchParams }: Props) {
|
|||||||
<SignUpMethodsContainer
|
<SignUpMethodsContainer
|
||||||
providers={authConfig.providers}
|
providers={authConfig.providers}
|
||||||
displayTermsCheckbox={authConfig.displayTermsCheckbox}
|
displayTermsCheckbox={authConfig.displayTermsCheckbox}
|
||||||
inviteToken={inviteToken}
|
|
||||||
paths={paths}
|
paths={paths}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={'flex justify-center'}>
|
<div className={'flex justify-center'}>
|
||||||
<Button asChild variant={'link'} size={'sm'}>
|
<Button asChild variant={'link'} size={'sm'}>
|
||||||
<Link href={signInPath} prefetch={true}>
|
<Link href={pathsConfig.auth.signIn} prefetch={true}>
|
||||||
<Trans i18nKey={'auth:alreadyHaveAnAccount'} />
|
<Trans i18nKey={'auth:alreadyHaveAnAccount'} />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -56,12 +56,12 @@ async function JoinTeamAccountPage(props: JoinTeamAccountPageProps) {
|
|||||||
|
|
||||||
const verifyMfaUrl = `${pathsConfig.auth.verifyMfa}?${urlParams.toString()}`;
|
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);
|
redirect(verifyMfaUrl);
|
||||||
} else {
|
} else {
|
||||||
const urlParams = new URLSearchParams({
|
const urlParams = new URLSearchParams({
|
||||||
invite_token: token,
|
invite_token: token,
|
||||||
email: searchParams.email ?? '',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const nextUrl = `${pathsConfig.auth.signUp}?${urlParams.toString()}`;
|
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
|
// the user is logged in, we can now check if the token is valid
|
||||||
const invitation = await api.getInvitation(adminClient, token);
|
const invitation = await api.getInvitation(adminClient, token);
|
||||||
|
|
||||||
// the invitation is not found or expired
|
// the invitation is not found or expired or the email is not the same as the user's email
|
||||||
if (!invitation) {
|
const isInvitationValid = invitation?.email === auth.data.email;
|
||||||
|
|
||||||
|
if (!isInvitationValid) {
|
||||||
return (
|
return (
|
||||||
<AuthLayoutShell Logo={AppLogo}>
|
<AuthLayoutShell Logo={AppLogo}>
|
||||||
<InviteNotFoundOrExpired />
|
<InviteNotFoundOrExpired />
|
||||||
|
|||||||
@@ -135,5 +135,6 @@
|
|||||||
"accountUnlinked": "Account successfully unlinked",
|
"accountUnlinked": "Account successfully unlinked",
|
||||||
"linkEmailPassword": "Email & Password",
|
"linkEmailPassword": "Email & Password",
|
||||||
"linkEmailPasswordDescription": "Add an email and password to your account for additional sign-in options",
|
"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}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,19 +5,6 @@
|
|||||||
-- In production, you should manually create webhooks in the Supabase dashboard (or create a migration to do so).
|
-- 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.
|
-- 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
|
-- 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)
|
-- which should happen when a user deletes their account (and all their subscriptions)
|
||||||
create trigger "subscriptions_delete"
|
create trigger "subscriptions_delete"
|
||||||
@@ -32,21 +19,6 @@ execute function "supabase_functions"."http_request"(
|
|||||||
'5000'
|
'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
|
-- DATA SEED
|
||||||
-- This is a data dump for testing purposes. It should be used to seed the database with data for testing.
|
-- This is a data dump for testing purposes. It should be used to seed the database with data for testing.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "next-supabase-saas-kit-turbo",
|
"name": "next-supabase-saas-kit-turbo",
|
||||||
"version": "2.17.1",
|
"version": "2.18.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -20,7 +20,6 @@
|
|||||||
"@kit/shared": "workspace:*",
|
"@kit/shared": "workspace:*",
|
||||||
"@kit/stripe": "workspace:*",
|
"@kit/stripe": "workspace:*",
|
||||||
"@kit/supabase": "workspace:*",
|
"@kit/supabase": "workspace:*",
|
||||||
"@kit/team-accounts": "workspace:*",
|
|
||||||
"@kit/tsconfig": "workspace:*",
|
"@kit/tsconfig": "workspace:*",
|
||||||
"@supabase/supabase-js": "2.58.0",
|
"@supabase/supabase-js": "2.58.0",
|
||||||
"zod": "^3.25.74"
|
"zod": "^3.25.74"
|
||||||
|
|||||||
@@ -24,40 +24,14 @@ class DatabaseWebhookRouterService {
|
|||||||
*/
|
*/
|
||||||
async handleWebhook(body: RecordChange<keyof Tables>) {
|
async handleWebhook(body: RecordChange<keyof Tables>) {
|
||||||
switch (body.table) {
|
switch (body.table) {
|
||||||
case 'invitations': {
|
|
||||||
const payload = body as RecordChange<typeof body.table>;
|
|
||||||
|
|
||||||
return this.handleInvitationsWebhook(payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'subscriptions': {
|
case 'subscriptions': {
|
||||||
const payload = body as RecordChange<typeof body.table>;
|
const payload = body as RecordChange<typeof body.table>;
|
||||||
|
|
||||||
return this.handleSubscriptionsWebhook(payload);
|
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(
|
private async handleSubscriptionsWebhook(
|
||||||
body: RecordChange<'subscriptions'>,
|
body: RecordChange<'subscriptions'>,
|
||||||
) {
|
) {
|
||||||
@@ -71,16 +45,4 @@ class DatabaseWebhookRouterService {
|
|||||||
return service.handleSubscriptionDeletedWebhook(body.old_record);
|
return service.handleSubscriptionDeletedWebhook(body.old_record);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleAccountsWebhook(body: RecordChange<'accounts'>) {
|
|
||||||
if (body.type === 'DELETE' && body.old_record) {
|
|
||||||
const { createAccountWebhooksService } = await import(
|
|
||||||
'@kit/team-accounts/webhooks'
|
|
||||||
);
|
|
||||||
|
|
||||||
const service = createAccountWebhooksService();
|
|
||||||
|
|
||||||
return service.handleAccountDeletedWebhook(body.old_record);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { initializeEmailI18n } from '../lib/i18n';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
productName: string;
|
productName: string;
|
||||||
userDisplayName: string;
|
|
||||||
language?: string;
|
language?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,9 +54,7 @@ export async function renderAccountDeleteEmail(props: Props) {
|
|||||||
|
|
||||||
<EmailContent>
|
<EmailContent>
|
||||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||||
{t(`${namespace}:hello`, {
|
{t(`${namespace}:hello`)}
|
||||||
displayName: props.userDisplayName,
|
|
||||||
})}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { CheckIcon } from '@radix-ui/react-icons';
|
import { CheckIcon } from '@radix-ui/react-icons';
|
||||||
|
import { Mail } from 'lucide-react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -13,11 +14,14 @@ import {
|
|||||||
FormControl,
|
FormControl,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@kit/ui/form';
|
} from '@kit/ui/form';
|
||||||
import { If } from '@kit/ui/if';
|
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 { toast } from '@kit/ui/sonner';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
@@ -89,50 +93,57 @@ export function UpdateEmailForm({
|
|||||||
</If>
|
</If>
|
||||||
|
|
||||||
<div className={'flex flex-col space-y-4'}>
|
<div className={'flex flex-col space-y-4'}>
|
||||||
<FormField
|
<div className="flex flex-col space-y-2">
|
||||||
render={({ field }) => (
|
<FormField
|
||||||
<FormItem>
|
render={({ field }) => (
|
||||||
<FormLabel>
|
<FormItem>
|
||||||
<Trans i18nKey={'account:newEmail'} />
|
<FormControl>
|
||||||
</FormLabel>
|
<InputGroup className="dark:bg-background">
|
||||||
|
<InputGroupAddon align="inline-start">
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
</InputGroupAddon>
|
||||||
|
|
||||||
<FormControl>
|
<InputGroupInput
|
||||||
<Input
|
data-test={'account-email-form-email-input'}
|
||||||
data-test={'account-email-form-email-input'}
|
required
|
||||||
required
|
type={'email'}
|
||||||
type={'email'}
|
placeholder={t('account:newEmail')}
|
||||||
placeholder={''}
|
{...field}
|
||||||
{...field}
|
/>
|
||||||
/>
|
</InputGroup>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
name={'email'}
|
name={'email'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormControl>
|
||||||
<Trans i18nKey={'account:repeatEmail'} />
|
<InputGroup className="dark:bg-background">
|
||||||
</FormLabel>
|
<InputGroupAddon align="inline-start">
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
</InputGroupAddon>
|
||||||
|
|
||||||
<FormControl>
|
<InputGroupInput
|
||||||
<Input
|
{...field}
|
||||||
{...field}
|
data-test={'account-email-form-repeat-email-input'}
|
||||||
data-test={'account-email-form-repeat-email-input'}
|
required
|
||||||
required
|
type={'email'}
|
||||||
type={'email'}
|
placeholder={t('account:repeatEmail')}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</InputGroup>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
name={'repeatEmail'}
|
name={'repeatEmail'}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Button disabled={updateUserMutation.isPending}>
|
<Button disabled={updateUserMutation.isPending}>
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ import {
|
|||||||
} from '@kit/ui/alert-dialog';
|
} from '@kit/ui/alert-dialog';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import { If } from '@kit/ui/if';
|
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 { OauthProviderLogoImage } from '@kit/ui/oauth-provider-logo-image';
|
||||||
import { Separator } from '@kit/ui/separator';
|
import { Separator } from '@kit/ui/separator';
|
||||||
import { toast } from '@kit/ui/sonner';
|
import { toast } from '@kit/ui/sonner';
|
||||||
@@ -90,73 +99,76 @@ export function LinkAccountsList(props: { providers: Provider[] }) {
|
|||||||
|
|
||||||
<div className="flex flex-col space-y-2">
|
<div className="flex flex-col space-y-2">
|
||||||
{connectedIdentities.map((identity) => (
|
{connectedIdentities.map((identity) => (
|
||||||
<div
|
<Item key={identity.id} variant="outline">
|
||||||
key={identity.id}
|
<ItemMedia>
|
||||||
className="bg-muted/50 flex h-14 items-center justify-between rounded-lg border p-3"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<OauthProviderLogoImage providerId={identity.provider} />
|
<OauthProviderLogoImage providerId={identity.provider} />
|
||||||
|
</ItemMedia>
|
||||||
|
|
||||||
<div className="flex flex-col">
|
<ItemContent>
|
||||||
<span className="flex items-center gap-x-2 text-sm font-medium capitalize">
|
<ItemHeader className="flex items-center gap-3">
|
||||||
<CheckCircle className="h-3 w-3 text-green-500" />
|
<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>{identity.provider}</span>
|
||||||
</span>
|
</ItemTitle>
|
||||||
|
|
||||||
<If condition={identity.identity_data?.email}>
|
<If condition={identity.identity_data?.email}>
|
||||||
<span className="text-muted-foreground text-xs">
|
<ItemDescription>
|
||||||
{identity.identity_data?.email as string}
|
{identity.identity_data?.email as string}
|
||||||
</span>
|
</ItemDescription>
|
||||||
</If>
|
</If>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ItemHeader>
|
||||||
|
</ItemContent>
|
||||||
|
|
||||||
<If condition={hasMultipleIdentities}>
|
<ItemActions>
|
||||||
<AlertDialog>
|
<If condition={hasMultipleIdentities}>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialog>
|
||||||
<Button
|
<AlertDialogTrigger asChild>
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
disabled={unlinkMutation.isPending}
|
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"
|
|
||||||
>
|
>
|
||||||
|
<If condition={unlinkMutation.isPending}>
|
||||||
|
<Spinner className="mr-2 h-3 w-3" />
|
||||||
|
</If>
|
||||||
<Trans i18nKey={'account:unlinkAccount'} />
|
<Trans i18nKey={'account:unlinkAccount'} />
|
||||||
</AlertDialogAction>
|
</Button>
|
||||||
</AlertDialogFooter>
|
</AlertDialogTrigger>
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
<AlertDialogContent>
|
||||||
</If>
|
<AlertDialogHeader>
|
||||||
</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
@@ -179,19 +191,28 @@ export function LinkAccountsList(props: { providers: Provider[] }) {
|
|||||||
|
|
||||||
<div className="flex flex-col space-y-2">
|
<div className="flex flex-col space-y-2">
|
||||||
{availableProviders.map((provider) => (
|
{availableProviders.map((provider) => (
|
||||||
<button
|
<Item
|
||||||
key={provider}
|
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)}
|
onClick={() => handleLinkAccount(provider)}
|
||||||
|
role="button"
|
||||||
|
className="hover:bg-muted/50"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<ItemMedia>
|
||||||
<OauthProviderLogoImage providerId={provider} />
|
<OauthProviderLogoImage providerId={provider} />
|
||||||
|
</ItemMedia>
|
||||||
|
|
||||||
<span className="text-sm font-medium capitalize">
|
<ItemContent>
|
||||||
{provider}
|
<ItemTitle className="capitalize">{provider}</ItemTitle>
|
||||||
</span>
|
|
||||||
</div>
|
<ItemDescription>
|
||||||
</button>
|
<Trans
|
||||||
|
i18nKey={'account:linkAccountDescription'}
|
||||||
|
values={{ provider }}
|
||||||
|
/>
|
||||||
|
</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ import {
|
|||||||
import { Badge } from '@kit/ui/badge';
|
import { Badge } from '@kit/ui/badge';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import { If } from '@kit/ui/if';
|
import { If } from '@kit/ui/if';
|
||||||
|
import {
|
||||||
|
Item,
|
||||||
|
ItemContent,
|
||||||
|
ItemDescription,
|
||||||
|
ItemMedia,
|
||||||
|
ItemTitle,
|
||||||
|
} from '@kit/ui/item';
|
||||||
import { toast } from '@kit/ui/sonner';
|
import { toast } from '@kit/ui/sonner';
|
||||||
import { Spinner } from '@kit/ui/spinner';
|
import { Spinner } from '@kit/ui/spinner';
|
||||||
import {
|
import {
|
||||||
@@ -100,17 +107,21 @@ function FactorsTableContainer(props: { userId: string }) {
|
|||||||
if (!allFactors.length) {
|
if (!allFactors.length) {
|
||||||
return (
|
return (
|
||||||
<div className={'flex flex-col space-y-4'}>
|
<div className={'flex flex-col space-y-4'}>
|
||||||
<Alert>
|
<Item variant="outline">
|
||||||
<ShieldCheck className={'h-4'} />
|
<ItemMedia>
|
||||||
|
<ShieldCheck className={'h-4'} />
|
||||||
|
</ItemMedia>
|
||||||
|
|
||||||
<AlertTitle>
|
<ItemContent>
|
||||||
<Trans i18nKey={'account:multiFactorAuthHeading'} />
|
<ItemTitle>
|
||||||
</AlertTitle>
|
<Trans i18nKey={'account:multiFactorAuthHeading'} />
|
||||||
|
</ItemTitle>
|
||||||
|
|
||||||
<AlertDescription>
|
<ItemDescription>
|
||||||
<Trans i18nKey={'account:multiFactorAuthDescription'} />
|
<Trans i18nKey={'account:multiFactorAuthDescription'} />
|
||||||
</AlertDescription>
|
</ItemDescription>
|
||||||
</Alert>
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
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 { useForm } from 'react-hook-form';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -17,12 +17,14 @@ import {
|
|||||||
FormDescription,
|
FormDescription,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@kit/ui/form';
|
} from '@kit/ui/form';
|
||||||
import { If } from '@kit/ui/if';
|
import { If } from '@kit/ui/if';
|
||||||
import { Input } from '@kit/ui/input';
|
import {
|
||||||
import { Label } from '@kit/ui/label';
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupInput,
|
||||||
|
} from '@kit/ui/input-group';
|
||||||
import { toast } from '@kit/ui/sonner';
|
import { toast } from '@kit/ui/sonner';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
@@ -101,62 +103,68 @@ export const UpdatePasswordForm = ({
|
|||||||
<NeedsReauthenticationAlert />
|
<NeedsReauthenticationAlert />
|
||||||
</If>
|
</If>
|
||||||
|
|
||||||
<FormField
|
<div className="flex flex-col space-y-2">
|
||||||
name={'newPassword'}
|
<FormField
|
||||||
render={({ field }) => {
|
name={'newPassword'}
|
||||||
return (
|
render={({ field }) => {
|
||||||
<FormItem>
|
return (
|
||||||
<FormLabel>
|
<FormItem>
|
||||||
<Label>
|
<FormControl>
|
||||||
<Trans i18nKey={'account:newPassword'} />
|
<InputGroup className="dark:bg-background">
|
||||||
</Label>
|
<InputGroupAddon align="inline-start">
|
||||||
</FormLabel>
|
<Lock className="h-4 w-4" />
|
||||||
|
</InputGroupAddon>
|
||||||
|
|
||||||
<FormControl>
|
<InputGroupInput
|
||||||
<Input
|
data-test={'account-password-form-password-input'}
|
||||||
data-test={'account-password-form-password-input'}
|
autoComplete={'new-password'}
|
||||||
autoComplete={'new-password'}
|
required
|
||||||
required
|
type={'password'}
|
||||||
type={'password'}
|
placeholder={t('account:newPassword')}
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</InputGroup>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
name={'repeatPassword'}
|
name={'repeatPassword'}
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormControl>
|
||||||
<Label>
|
<InputGroup className="dark:bg-background">
|
||||||
<Trans i18nKey={'account:repeatPassword'} />
|
<InputGroupAddon align="inline-start">
|
||||||
</Label>
|
<Lock className="h-4 w-4" />
|
||||||
</FormLabel>
|
</InputGroupAddon>
|
||||||
|
|
||||||
<FormControl>
|
<InputGroupInput
|
||||||
<Input
|
data-test={
|
||||||
data-test={'account-password-form-repeat-password-input'}
|
'account-password-form-repeat-password-input'
|
||||||
required
|
}
|
||||||
type={'password'}
|
required
|
||||||
{...field}
|
type={'password'}
|
||||||
/>
|
placeholder={t('account:repeatPassword')}
|
||||||
</FormControl>
|
{...field}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
<Trans i18nKey={'account:repeatPasswordDescription'} />
|
<Trans i18nKey={'account:repeatPasswordDescription'} />
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Button disabled={updateUserMutation.isPending}>
|
<Button disabled={updateUserMutation.isPending}>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { User } from 'lucide-react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -9,10 +10,13 @@ import {
|
|||||||
FormControl,
|
FormControl,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@kit/ui/form';
|
} 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 { toast } from '@kit/ui/sonner';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
@@ -66,18 +70,20 @@ export function UpdateAccountDetailsForm({
|
|||||||
name={'displayName'}
|
name={'displayName'}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
|
||||||
<Trans i18nKey={'account:name'} />
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<InputGroup className="dark:bg-background">
|
||||||
data-test={'account-display-name'}
|
<InputGroupAddon align="inline-start">
|
||||||
minLength={2}
|
<User className="h-4 w-4" />
|
||||||
placeholder={''}
|
</InputGroupAddon>
|
||||||
maxLength={100}
|
|
||||||
{...field}
|
<InputGroupInput
|
||||||
/>
|
data-test={'account-display-name'}
|
||||||
|
minLength={2}
|
||||||
|
placeholder={t('account:name')}
|
||||||
|
maxLength={100}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|||||||
@@ -85,8 +85,10 @@ export const deletePersonalAccountAction = enhanceAction(
|
|||||||
// delete the user's account and cancel all subscriptions
|
// delete the user's account and cancel all subscriptions
|
||||||
await service.deletePersonalAccount({
|
await service.deletePersonalAccount({
|
||||||
adminClient: getSupabaseServerAdminClient(),
|
adminClient: getSupabaseServerAdminClient(),
|
||||||
userId: user.id,
|
account: {
|
||||||
userEmail: user.email ?? null,
|
id: user.id,
|
||||||
|
email: user.email ?? null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// sign out the user after deleting their account
|
// sign out the user after deleting their account
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import 'server-only';
|
|||||||
|
|
||||||
import { SupabaseClient } from '@supabase/supabase-js';
|
import { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { getLogger } from '@kit/shared/logger';
|
import { getLogger } from '@kit/shared/logger';
|
||||||
import { Database } from '@kit/supabase/database';
|
import { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
@@ -30,13 +32,14 @@ class DeletePersonalAccountService {
|
|||||||
*/
|
*/
|
||||||
async deletePersonalAccount(params: {
|
async deletePersonalAccount(params: {
|
||||||
adminClient: SupabaseClient<Database>;
|
adminClient: SupabaseClient<Database>;
|
||||||
|
account: {
|
||||||
userId: string;
|
id: string;
|
||||||
userEmail: string | null;
|
email: string | null;
|
||||||
|
};
|
||||||
}) {
|
}) {
|
||||||
const logger = await getLogger();
|
const logger = await getLogger();
|
||||||
|
|
||||||
const userId = params.userId;
|
const userId = params.account.id;
|
||||||
const ctx = { userId, name: this.namespace };
|
const ctx = { userId, name: this.namespace };
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -54,6 +57,14 @@ class DeletePersonalAccountService {
|
|||||||
|
|
||||||
logger.info(ctx, 'User successfully deleted!');
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
};
|
};
|
||||||
@@ -69,4 +80,71 @@ class DeletePersonalAccountService {
|
|||||||
throw new Error('Error deleting user');
|
throw new Error('Error deleting user');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async dispatchDeleteAccountEmail(account: {
|
||||||
|
email: string;
|
||||||
|
id: string;
|
||||||
|
}) {
|
||||||
|
const logger = await getLogger();
|
||||||
|
const ctx = { name: this.namespace, userId: account.id };
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info(ctx, 'Sending delete account email...');
|
||||||
|
|
||||||
|
await this.sendDeleteAccountEmail(account);
|
||||||
|
|
||||||
|
logger.info(ctx, 'Delete account email sent successfully');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
{
|
||||||
|
...ctx,
|
||||||
|
error,
|
||||||
|
},
|
||||||
|
'Failed to send delete account email',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendDeleteAccountEmail(account: { email: string }) {
|
||||||
|
const emailSettings = this.getEmailSettings();
|
||||||
|
|
||||||
|
const { renderAccountDeleteEmail } = await import('@kit/email-templates');
|
||||||
|
const { getMailer } = await import('@kit/mailers');
|
||||||
|
|
||||||
|
const mailer = await getMailer();
|
||||||
|
|
||||||
|
const { html, subject } = await renderAccountDeleteEmail({
|
||||||
|
productName: emailSettings.productName,
|
||||||
|
});
|
||||||
|
|
||||||
|
await mailer.sendEmail({
|
||||||
|
from: emailSettings.fromEmail,
|
||||||
|
html,
|
||||||
|
subject,
|
||||||
|
to: account.email,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getEmailSettings() {
|
||||||
|
const productName = process.env.NEXT_PUBLIC_PRODUCT_NAME;
|
||||||
|
const fromEmail = process.env.EMAIL_SENDER;
|
||||||
|
|
||||||
|
return z
|
||||||
|
.object({
|
||||||
|
productName: z
|
||||||
|
.string({
|
||||||
|
required_error: 'NEXT_PUBLIC_PRODUCT_NAME is required',
|
||||||
|
})
|
||||||
|
.min(1),
|
||||||
|
fromEmail: z
|
||||||
|
.string({
|
||||||
|
required_error: 'EMAIL_SENDER is required',
|
||||||
|
})
|
||||||
|
.min(1),
|
||||||
|
})
|
||||||
|
.parse({
|
||||||
|
productName,
|
||||||
|
fromEmail,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,13 +28,11 @@ import { useLastAuthMethod } from '../hooks/use-last-auth-method';
|
|||||||
import { TermsAndConditionsFormField } from './terms-and-conditions-form-field';
|
import { TermsAndConditionsFormField } from './terms-and-conditions-form-field';
|
||||||
|
|
||||||
export function MagicLinkAuthContainer({
|
export function MagicLinkAuthContainer({
|
||||||
inviteToken,
|
|
||||||
redirectUrl,
|
redirectUrl,
|
||||||
shouldCreateUser,
|
shouldCreateUser,
|
||||||
defaultValues,
|
defaultValues,
|
||||||
displayTermsCheckbox,
|
displayTermsCheckbox,
|
||||||
}: {
|
}: {
|
||||||
inviteToken?: string;
|
|
||||||
redirectUrl: string;
|
redirectUrl: string;
|
||||||
shouldCreateUser: boolean;
|
shouldCreateUser: boolean;
|
||||||
displayTermsCheckbox?: boolean;
|
displayTermsCheckbox?: boolean;
|
||||||
@@ -63,10 +61,6 @@ export function MagicLinkAuthContainer({
|
|||||||
const onSubmit = ({ email }: { email: string }) => {
|
const onSubmit = ({ email }: { email: string }) => {
|
||||||
const url = new URL(redirectUrl);
|
const url = new URL(redirectUrl);
|
||||||
|
|
||||||
if (inviteToken) {
|
|
||||||
url.searchParams.set('invite_token', inviteToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
const emailRedirectTo = url.href;
|
const emailRedirectTo = url.href;
|
||||||
|
|
||||||
const promise = async () => {
|
const promise = async () => {
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ const OAUTH_SCOPES: Partial<Record<Provider, string>> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const OauthProviders: React.FC<{
|
export const OauthProviders: React.FC<{
|
||||||
inviteToken?: string;
|
|
||||||
shouldCreateUser: boolean;
|
shouldCreateUser: boolean;
|
||||||
enabledProviders: Provider[];
|
enabledProviders: Provider[];
|
||||||
queryParams?: Record<string, string>;
|
queryParams?: Record<string, string>;
|
||||||
@@ -86,10 +85,6 @@ export const OauthProviders: React.FC<{
|
|||||||
queryParams.set('next', props.paths.returnPath);
|
queryParams.set('next', props.paths.returnPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.inviteToken) {
|
|
||||||
queryParams.set('invite_token', props.inviteToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
const redirectPath = [
|
const redirectPath = [
|
||||||
props.paths.callback,
|
props.paths.callback,
|
||||||
queryParams.toString(),
|
queryParams.toString(),
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ const OtpSchema = z.object({ token: z.string().min(6).max(6) });
|
|||||||
|
|
||||||
type OtpSignInContainerProps = {
|
type OtpSignInContainerProps = {
|
||||||
shouldCreateUser: boolean;
|
shouldCreateUser: boolean;
|
||||||
inviteToken?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function OtpSignInContainer(props: OtpSignInContainerProps) {
|
export function OtpSignInContainer(props: OtpSignInContainerProps) {
|
||||||
@@ -80,19 +79,9 @@ export function OtpSignInContainer(props: OtpSignInContainerProps) {
|
|||||||
recordAuthMethod('otp', { email });
|
recordAuthMethod('otp', { email });
|
||||||
|
|
||||||
// on sign ups we redirect to the app home
|
// on sign ups we redirect to the app home
|
||||||
const inviteToken = props.inviteToken;
|
|
||||||
const next = params.get('next') ?? '/home';
|
const next = params.get('next') ?? '/home';
|
||||||
|
|
||||||
if (inviteToken) {
|
router.replace(next);
|
||||||
const params = new URLSearchParams({
|
|
||||||
invite_token: inviteToken,
|
|
||||||
next,
|
|
||||||
});
|
|
||||||
|
|
||||||
router.replace(`/join?${params.toString()}`);
|
|
||||||
} else {
|
|
||||||
router.replace(next);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEmailStep) {
|
if (isEmailStep) {
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ import { OtpSignInContainer } from './otp-sign-in-container';
|
|||||||
import { PasswordSignInContainer } from './password-sign-in-container';
|
import { PasswordSignInContainer } from './password-sign-in-container';
|
||||||
|
|
||||||
export function SignInMethodsContainer(props: {
|
export function SignInMethodsContainer(props: {
|
||||||
inviteToken?: string;
|
|
||||||
|
|
||||||
paths: {
|
paths: {
|
||||||
callback: string;
|
callback: string;
|
||||||
joinTeam: string;
|
joinTeam: string;
|
||||||
@@ -40,22 +38,10 @@ export function SignInMethodsContainer(props: {
|
|||||||
: '';
|
: '';
|
||||||
|
|
||||||
const onSignIn = useCallback(() => {
|
const onSignIn = useCallback(() => {
|
||||||
// if the user has an invite token, we should join the team
|
const returnPath = props.paths.returnPath || '/home';
|
||||||
if (props.inviteToken) {
|
|
||||||
const searchParams = new URLSearchParams({
|
|
||||||
invite_token: props.inviteToken,
|
|
||||||
});
|
|
||||||
|
|
||||||
const joinTeamPath = props.paths.joinTeam + '?' + searchParams.toString();
|
router.replace(returnPath);
|
||||||
|
}, [props.paths.returnPath, router]);
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -67,17 +53,13 @@ export function SignInMethodsContainer(props: {
|
|||||||
|
|
||||||
<If condition={props.providers.magicLink}>
|
<If condition={props.providers.magicLink}>
|
||||||
<MagicLinkAuthContainer
|
<MagicLinkAuthContainer
|
||||||
inviteToken={props.inviteToken}
|
|
||||||
redirectUrl={redirectUrl}
|
redirectUrl={redirectUrl}
|
||||||
shouldCreateUser={false}
|
shouldCreateUser={false}
|
||||||
/>
|
/>
|
||||||
</If>
|
</If>
|
||||||
|
|
||||||
<If condition={props.providers.otp}>
|
<If condition={props.providers.otp}>
|
||||||
<OtpSignInContainer
|
<OtpSignInContainer shouldCreateUser={false} />
|
||||||
inviteToken={props.inviteToken}
|
|
||||||
shouldCreateUser={false}
|
|
||||||
/>
|
|
||||||
</If>
|
</If>
|
||||||
|
|
||||||
<If condition={props.providers.oAuth.length}>
|
<If condition={props.providers.oAuth.length}>
|
||||||
@@ -95,7 +77,6 @@ export function SignInMethodsContainer(props: {
|
|||||||
|
|
||||||
<OauthProviders
|
<OauthProviders
|
||||||
enabledProviders={props.providers.oAuth}
|
enabledProviders={props.providers.oAuth}
|
||||||
inviteToken={props.inviteToken}
|
|
||||||
shouldCreateUser={false}
|
shouldCreateUser={false}
|
||||||
paths={{
|
paths={{
|
||||||
callback: props.paths.callback,
|
callback: props.paths.callback,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import type { Provider } from '@supabase/supabase-js';
|
import type { Provider } from '@supabase/supabase-js';
|
||||||
|
|
||||||
import { isBrowser } from '@kit/shared/utils';
|
import { isBrowser } from '@kit/shared/utils';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
|
||||||
import { If } from '@kit/ui/if';
|
import { If } from '@kit/ui/if';
|
||||||
import { Separator } from '@kit/ui/separator';
|
import { Separator } from '@kit/ui/separator';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
@@ -28,7 +27,6 @@ export function SignUpMethodsContainer(props: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
displayTermsCheckbox?: boolean;
|
displayTermsCheckbox?: boolean;
|
||||||
inviteToken?: string;
|
|
||||||
}) {
|
}) {
|
||||||
const redirectUrl = getCallbackUrl(props);
|
const redirectUrl = getCallbackUrl(props);
|
||||||
const defaultValues = getDefaultValues();
|
const defaultValues = getDefaultValues();
|
||||||
@@ -38,10 +36,6 @@ export function SignUpMethodsContainer(props: {
|
|||||||
{/* Show hint if user might already have an account */}
|
{/* Show hint if user might already have an account */}
|
||||||
<ExistingAccountHint />
|
<ExistingAccountHint />
|
||||||
|
|
||||||
<If condition={props.inviteToken}>
|
|
||||||
<InviteAlert />
|
|
||||||
</If>
|
|
||||||
|
|
||||||
<If condition={props.providers.password}>
|
<If condition={props.providers.password}>
|
||||||
<EmailPasswordSignUpContainer
|
<EmailPasswordSignUpContainer
|
||||||
emailRedirectTo={redirectUrl}
|
emailRedirectTo={redirectUrl}
|
||||||
@@ -51,15 +45,11 @@ export function SignUpMethodsContainer(props: {
|
|||||||
</If>
|
</If>
|
||||||
|
|
||||||
<If condition={props.providers.otp}>
|
<If condition={props.providers.otp}>
|
||||||
<OtpSignInContainer
|
<OtpSignInContainer shouldCreateUser={true} />
|
||||||
inviteToken={props.inviteToken}
|
|
||||||
shouldCreateUser={true}
|
|
||||||
/>
|
|
||||||
</If>
|
</If>
|
||||||
|
|
||||||
<If condition={props.providers.magicLink}>
|
<If condition={props.providers.magicLink}>
|
||||||
<MagicLinkAuthContainer
|
<MagicLinkAuthContainer
|
||||||
inviteToken={props.inviteToken}
|
|
||||||
redirectUrl={redirectUrl}
|
redirectUrl={redirectUrl}
|
||||||
shouldCreateUser={true}
|
shouldCreateUser={true}
|
||||||
defaultValues={defaultValues}
|
defaultValues={defaultValues}
|
||||||
@@ -82,7 +72,6 @@ export function SignUpMethodsContainer(props: {
|
|||||||
|
|
||||||
<OauthProviders
|
<OauthProviders
|
||||||
enabledProviders={props.providers.oAuth}
|
enabledProviders={props.providers.oAuth}
|
||||||
inviteToken={props.inviteToken}
|
|
||||||
shouldCreateUser={true}
|
shouldCreateUser={true}
|
||||||
paths={{
|
paths={{
|
||||||
callback: props.paths.callback,
|
callback: props.paths.callback,
|
||||||
@@ -99,8 +88,6 @@ function getCallbackUrl(props: {
|
|||||||
callback: string;
|
callback: string;
|
||||||
appHome: string;
|
appHome: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
inviteToken?: string;
|
|
||||||
}) {
|
}) {
|
||||||
if (!isBrowser()) {
|
if (!isBrowser()) {
|
||||||
return '';
|
return '';
|
||||||
@@ -110,10 +97,6 @@ function getCallbackUrl(props: {
|
|||||||
const origin = window.location.origin;
|
const origin = window.location.origin;
|
||||||
const url = new URL(redirectPath, 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 searchParams = new URLSearchParams(window.location.search);
|
||||||
const next = searchParams.get('next');
|
const next = searchParams.get('next');
|
||||||
|
|
||||||
@@ -130,27 +113,8 @@ function getDefaultValues() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
const inviteToken = searchParams.get('invite_token');
|
|
||||||
|
|
||||||
if (!inviteToken) {
|
|
||||||
return { email: '' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
email: searchParams.get('email') ?? '',
|
email: searchParams.get('email') ?? '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function InviteAlert() {
|
|
||||||
return (
|
|
||||||
<Alert variant={'info'}>
|
|
||||||
<AlertTitle>
|
|
||||||
<Trans i18nKey={'auth:inviteAlertHeading'} />
|
|
||||||
</AlertTitle>
|
|
||||||
|
|
||||||
<AlertDescription>
|
|
||||||
<Trans i18nKey={'auth:inviteAlertBody'} />
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useTransition } from 'react';
|
|||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
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 { useFieldArray, useForm } from 'react-hook-form';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -23,11 +23,14 @@ import {
|
|||||||
FormControl,
|
FormControl,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@kit/ui/form';
|
} from '@kit/ui/form';
|
||||||
import { If } from '@kit/ui/if';
|
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 { toast } from '@kit/ui/sonner';
|
||||||
import { Spinner } from '@kit/ui/spinner';
|
import { Spinner } from '@kit/ui/spinner';
|
||||||
import {
|
import {
|
||||||
@@ -188,28 +191,26 @@ function InviteMembersForm({
|
|||||||
data-test={'invite-members-form'}
|
data-test={'invite-members-form'}
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
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) => {
|
{fieldArray.fields.map((field, index) => {
|
||||||
const isFirst = index === 0;
|
|
||||||
|
|
||||||
const emailInputName = `invitations.${index}.email` as const;
|
const emailInputName = `invitations.${index}.email` as const;
|
||||||
const roleInputName = `invitations.${index}.role` as const;
|
const roleInputName = `invitations.${index}.role` as const;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-test={'invite-member-form-item'} key={field.id}>
|
<div data-test={'invite-member-form-item'} key={field.id}>
|
||||||
<div className={'flex items-end gap-x-1 md:space-x-2'}>
|
<div className={'flex items-end gap-x-2'}>
|
||||||
<div className={'w-7/12'}>
|
<InputGroup className={'bg-background w-full'}>
|
||||||
|
<InputGroupAddon align="inline-start">
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
</InputGroupAddon>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
name={emailInputName}
|
name={emailInputName}
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem className="w-full">
|
||||||
<If condition={isFirst}>
|
|
||||||
<FormLabel>{t('emailLabel')}</FormLabel>
|
|
||||||
</If>
|
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<InputGroupInput
|
||||||
data-test={'invite-email-input'}
|
data-test={'invite-email-input'}
|
||||||
placeholder={t('emailPlaceholder')}
|
placeholder={t('emailPlaceholder')}
|
||||||
type="email"
|
type="email"
|
||||||
@@ -223,39 +224,31 @@ function InviteMembersForm({
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</InputGroup>
|
||||||
|
|
||||||
<div className={'w-4/12'}>
|
<FormField
|
||||||
<FormField
|
name={roleInputName}
|
||||||
name={roleInputName}
|
render={({ field }) => {
|
||||||
render={({ field }) => {
|
return (
|
||||||
return (
|
<FormItem>
|
||||||
<FormItem>
|
<FormControl>
|
||||||
<If condition={isFirst}>
|
<MembershipRoleSelector
|
||||||
<FormLabel>
|
triggerClassName={'m-0 bg-muted'}
|
||||||
<Trans i18nKey={'teams:roleLabel'} />
|
roles={roles}
|
||||||
</FormLabel>
|
value={field.value}
|
||||||
</If>
|
onChange={(role) => {
|
||||||
|
form.setValue(field.name, role);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
<FormControl>
|
<FormMessage />
|
||||||
<MembershipRoleSelector
|
</FormItem>
|
||||||
triggerClassName={'m-0'}
|
);
|
||||||
roles={roles}
|
}}
|
||||||
value={field.value}
|
/>
|
||||||
onChange={(role) => {
|
|
||||||
form.setValue(field.name, role);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormMessage />
|
<div className={'flex items-end justify-end'}>
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={'flex w-[40px] items-end justify-end'}>
|
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -271,7 +264,7 @@ function InviteMembersForm({
|
|||||||
form.clearErrors(emailInputName);
|
form.clearErrors(emailInputName);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<X className={'h-4 lg:h-5'} />
|
<X className={'h-4'} />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useTransition } from 'react';
|
|||||||
import { isRedirectError } from 'next/dist/client/components/redirect-error';
|
import { isRedirectError } from 'next/dist/client/components/redirect-error';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Building } from 'lucide-react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -14,10 +15,13 @@ import {
|
|||||||
FormControl,
|
FormControl,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@kit/ui/form';
|
} 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 { toast } from '@kit/ui/sonner';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
@@ -87,17 +91,19 @@ export const UpdateTeamAccountNameForm = (props: {
|
|||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
|
||||||
<Trans i18nKey={'teams:teamNameInputLabel'} />
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<InputGroup className="dark:bg-background">
|
||||||
data-test={'team-name-input'}
|
<InputGroupAddon align="inline-start">
|
||||||
required
|
<Building className="h-4 w-4" />
|
||||||
placeholder={''}
|
</InputGroupAddon>
|
||||||
{...field}
|
|
||||||
/>
|
<InputGroupInput
|
||||||
|
data-test={'team-name-input'}
|
||||||
|
required
|
||||||
|
placeholder={t('teams:teamNameInputLabel')}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ export const acceptInvitationAction = enhanceAction(
|
|||||||
const accountId = await service.acceptInvitationToTeam(adminClient, {
|
const accountId = await service.acceptInvitationToTeam(adminClient, {
|
||||||
inviteToken,
|
inviteToken,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
userEmail: user.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
// If the account ID is not present, throw an error
|
// If the account ID is not present, throw an error
|
||||||
|
|||||||
@@ -209,6 +209,8 @@ export class TeamAccountsApi {
|
|||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
id: string;
|
id: string;
|
||||||
|
email: string;
|
||||||
|
|
||||||
account: {
|
account: {
|
||||||
id: string;
|
id: string;
|
||||||
name: 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)
|
.eq('invite_token', token)
|
||||||
.gte('expires_at', new Date().toISOString())
|
.gte('expires_at', new Date().toISOString())
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { getLogger } from '@kit/shared/logger';
|
||||||
|
import { Database, Tables } from '@kit/supabase/database';
|
||||||
|
|
||||||
|
type Invitation = Tables<'invitations'>;
|
||||||
|
|
||||||
|
const invitePath = '/join';
|
||||||
|
const authTokenCallbackPath = '/auth/confirm';
|
||||||
|
|
||||||
|
const siteURL = process.env.NEXT_PUBLIC_SITE_URL;
|
||||||
|
const productName = process.env.NEXT_PUBLIC_PRODUCT_NAME ?? '';
|
||||||
|
const emailSender = process.env.EMAIL_SENDER;
|
||||||
|
|
||||||
|
const env = z
|
||||||
|
.object({
|
||||||
|
invitePath: z
|
||||||
|
.string({
|
||||||
|
required_error: 'The property invitePath is required',
|
||||||
|
})
|
||||||
|
.min(1),
|
||||||
|
siteURL: z
|
||||||
|
.string({
|
||||||
|
required_error: 'NEXT_PUBLIC_SITE_URL is required',
|
||||||
|
})
|
||||||
|
.min(1),
|
||||||
|
productName: z
|
||||||
|
.string({
|
||||||
|
required_error: 'NEXT_PUBLIC_PRODUCT_NAME is required',
|
||||||
|
})
|
||||||
|
.min(1),
|
||||||
|
emailSender: z
|
||||||
|
.string({
|
||||||
|
required_error: 'EMAIL_SENDER is required',
|
||||||
|
})
|
||||||
|
.min(1),
|
||||||
|
})
|
||||||
|
.parse({
|
||||||
|
invitePath,
|
||||||
|
siteURL,
|
||||||
|
productName,
|
||||||
|
emailSender,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function createAccountInvitationsDispatchService(
|
||||||
|
client: SupabaseClient<Database>,
|
||||||
|
) {
|
||||||
|
return new AccountInvitationsDispatchService(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AccountInvitationsDispatchService {
|
||||||
|
private namespace = 'accounts.invitations.webhook';
|
||||||
|
|
||||||
|
constructor(private readonly adminClient: SupabaseClient<Database>) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name sendInvitationEmail
|
||||||
|
* @description Sends an invitation email to the invited user
|
||||||
|
* @param invitation - The invitation to send
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
async sendInvitationEmail({
|
||||||
|
invitation,
|
||||||
|
link,
|
||||||
|
}: {
|
||||||
|
invitation: Invitation;
|
||||||
|
link: string;
|
||||||
|
}) {
|
||||||
|
const logger = await getLogger();
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
invitationId: invitation.id,
|
||||||
|
name: this.namespace,
|
||||||
|
},
|
||||||
|
'Handling invitation email dispatch...',
|
||||||
|
);
|
||||||
|
|
||||||
|
// retrieve the inviter details
|
||||||
|
const inviter = await this.getInviterDetails(invitation);
|
||||||
|
|
||||||
|
if (inviter.error) {
|
||||||
|
logger.error(
|
||||||
|
{
|
||||||
|
error: inviter.error,
|
||||||
|
name: this.namespace,
|
||||||
|
},
|
||||||
|
'Failed to fetch inviter details',
|
||||||
|
);
|
||||||
|
|
||||||
|
throw inviter.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// retrieve the team details
|
||||||
|
const team = await this.getTeamDetails(invitation.account_id);
|
||||||
|
|
||||||
|
if (team.error) {
|
||||||
|
logger.error(
|
||||||
|
{
|
||||||
|
error: team.error,
|
||||||
|
name: this.namespace,
|
||||||
|
},
|
||||||
|
'Failed to fetch team details',
|
||||||
|
);
|
||||||
|
|
||||||
|
throw team.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
invitationId: invitation.id,
|
||||||
|
name: this.namespace,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info(ctx, 'Invite retrieved. Sending invitation email...');
|
||||||
|
|
||||||
|
// send the invitation email
|
||||||
|
await this.sendEmail({
|
||||||
|
invitation,
|
||||||
|
link,
|
||||||
|
inviter: inviter.data,
|
||||||
|
team: team.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ error, ...ctx }, 'Failed to invite user to team');
|
||||||
|
|
||||||
|
return {
|
||||||
|
error,
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name getInvitationLink
|
||||||
|
* @description Generates an invitation link for the given token and email
|
||||||
|
* @param token - The token to use for the invitation
|
||||||
|
*/
|
||||||
|
getInvitationLink(token: string) {
|
||||||
|
const siteUrl = env.siteURL;
|
||||||
|
const url = new URL(env.invitePath, siteUrl);
|
||||||
|
|
||||||
|
url.searchParams.set('invite_token', token);
|
||||||
|
|
||||||
|
return url.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name sendEmail
|
||||||
|
* @description Sends an invitation email to the invited user
|
||||||
|
* @param invitation - The invitation to send
|
||||||
|
* @param link - The link to the invitation
|
||||||
|
* @param inviter - The inviter details
|
||||||
|
* @param team - The team details
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
private async sendEmail({
|
||||||
|
invitation,
|
||||||
|
link,
|
||||||
|
inviter,
|
||||||
|
team,
|
||||||
|
}: {
|
||||||
|
invitation: Invitation;
|
||||||
|
link: string;
|
||||||
|
inviter: { name: string; email: string | null };
|
||||||
|
team: { name: string };
|
||||||
|
}) {
|
||||||
|
const logger = await getLogger();
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
invitationId: invitation.id,
|
||||||
|
name: this.namespace,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { renderInviteEmail } = await import('@kit/email-templates');
|
||||||
|
const { getMailer } = await import('@kit/mailers');
|
||||||
|
|
||||||
|
const mailer = await getMailer();
|
||||||
|
|
||||||
|
const { html, subject } = await renderInviteEmail({
|
||||||
|
link,
|
||||||
|
invitedUserEmail: invitation.email,
|
||||||
|
inviter: inviter.name ?? inviter.email ?? '',
|
||||||
|
productName: env.productName,
|
||||||
|
teamName: team.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
return mailer
|
||||||
|
.sendEmail({
|
||||||
|
from: env.emailSender,
|
||||||
|
to: invitation.email,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
logger.info(ctx, 'Invitation email successfully sent!');
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
logger.error({ error, ...ctx }, 'Failed to send invitation email');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name getAuthCallbackUrl
|
||||||
|
* @description Generates an auth token callback url. This redirects the user to a page where the user can sign in with a token.
|
||||||
|
* @param nextLink - The next link to redirect the user to
|
||||||
|
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
getAuthCallbackUrl(nextLink: string) {
|
||||||
|
const url = new URL(authTokenCallbackPath, env.siteURL);
|
||||||
|
|
||||||
|
url.searchParams.set('next', nextLink);
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name getInviterDetails
|
||||||
|
* @description Fetches the inviter details for the given invitation
|
||||||
|
* @param invitation
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
private getInviterDetails(invitation: Invitation) {
|
||||||
|
return this.adminClient
|
||||||
|
.from('accounts')
|
||||||
|
.select('email, name')
|
||||||
|
.eq('id', invitation.invited_by)
|
||||||
|
.single();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name getTeamDetails
|
||||||
|
* @description Fetches the team details for the given account ID
|
||||||
|
* @param accountId
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
private getTeamDetails(accountId: string) {
|
||||||
|
return this.adminClient
|
||||||
|
.from('accounts')
|
||||||
|
.select('name')
|
||||||
|
.eq('id', accountId)
|
||||||
|
.single();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,10 +7,12 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { getLogger } from '@kit/shared/logger';
|
import { getLogger } from '@kit/shared/logger';
|
||||||
import { Database } from '@kit/supabase/database';
|
import { Database } from '@kit/supabase/database';
|
||||||
|
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||||
|
|
||||||
import type { DeleteInvitationSchema } from '../../schema/delete-invitation.schema';
|
import type { DeleteInvitationSchema } from '../../schema/delete-invitation.schema';
|
||||||
import type { InviteMembersSchema } from '../../schema/invite-members.schema';
|
import type { InviteMembersSchema } from '../../schema/invite-members.schema';
|
||||||
import type { UpdateInvitationSchema } from '../../schema/update-invitation.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',
|
'Invitations added to account',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await this.dispatchInvitationEmails(ctx, responseInvitations);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -222,10 +226,12 @@ class AccountInvitationsService {
|
|||||||
adminClient: SupabaseClient<Database>,
|
adminClient: SupabaseClient<Database>,
|
||||||
params: {
|
params: {
|
||||||
userId: string;
|
userId: string;
|
||||||
|
userEmail: string;
|
||||||
inviteToken: string;
|
inviteToken: string;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const logger = await getLogger();
|
const logger = await getLogger();
|
||||||
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
name: this.namespace,
|
name: this.namespace,
|
||||||
...params,
|
...params,
|
||||||
@@ -233,6 +239,30 @@ class AccountInvitationsService {
|
|||||||
|
|
||||||
logger.info(ctx, 'Accepting invitation to team');
|
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', {
|
const { error, data } = await adminClient.rpc('accept_invitation', {
|
||||||
token: params.inviteToken,
|
token: params.inviteToken,
|
||||||
user_id: params.userId,
|
user_id: params.userId,
|
||||||
@@ -297,4 +327,128 @@ class AccountInvitationsService {
|
|||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name dispatchInvitationEmails
|
||||||
|
* @description Dispatches invitation emails to the invited users.
|
||||||
|
* @param ctx
|
||||||
|
* @param invitations
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
private async dispatchInvitationEmails(
|
||||||
|
ctx: { accountSlug: string; name: string },
|
||||||
|
invitations: Database['public']['Tables']['invitations']['Row'][],
|
||||||
|
) {
|
||||||
|
if (!invitations.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const logger = await getLogger();
|
||||||
|
const adminClient = getSupabaseServerAdminClient();
|
||||||
|
const service = createAccountInvitationsDispatchService(this.client);
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
invitations.map(async (invitation) => {
|
||||||
|
const joinTeamLink = service.getInvitationLink(invitation.invite_token);
|
||||||
|
const authCallbackUrl = service.getAuthCallbackUrl(joinTeamLink);
|
||||||
|
|
||||||
|
const getEmailLinkType = async () => {
|
||||||
|
const user = await adminClient
|
||||||
|
.from('accounts')
|
||||||
|
.select('*')
|
||||||
|
.eq('email', invitation.email)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
// if the user is not found, return the invite type
|
||||||
|
// this link allows the user to register to the platform
|
||||||
|
if (user.error || !user.data) {
|
||||||
|
return 'invite';
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the user is found, return the email link type to sign in
|
||||||
|
return 'magiclink';
|
||||||
|
};
|
||||||
|
|
||||||
|
const emailLinkType = await getEmailLinkType();
|
||||||
|
|
||||||
|
// generate an invitation link with Supabase admin client
|
||||||
|
// use the "redirectTo" parameter to redirect the user to the invitation page after the link is clicked
|
||||||
|
const generateLinkResponse = await adminClient.auth.admin.generateLink({
|
||||||
|
email: invitation.email,
|
||||||
|
type: emailLinkType,
|
||||||
|
});
|
||||||
|
|
||||||
|
// if the link generation fails, throw an error
|
||||||
|
if (generateLinkResponse.error) {
|
||||||
|
logger.error(
|
||||||
|
{
|
||||||
|
...ctx,
|
||||||
|
error: generateLinkResponse.error,
|
||||||
|
},
|
||||||
|
'Failed to generate link',
|
||||||
|
);
|
||||||
|
|
||||||
|
throw generateLinkResponse.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the link from the response
|
||||||
|
const verifyLink = generateLinkResponse.data.properties?.action_link;
|
||||||
|
|
||||||
|
// extract token
|
||||||
|
const token = new URL(verifyLink).searchParams.get('token');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
// return error
|
||||||
|
throw new Error(
|
||||||
|
'Token in verify link from Supabase Auth was not found',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// add search params to be consumed by /auth/confirm route
|
||||||
|
authCallbackUrl.searchParams.set('token_hash', token);
|
||||||
|
authCallbackUrl.searchParams.set('type', emailLinkType);
|
||||||
|
|
||||||
|
const link = authCallbackUrl.href;
|
||||||
|
|
||||||
|
// send the invitation email
|
||||||
|
const data = await service.sendInvitationEmail({
|
||||||
|
invitation,
|
||||||
|
link,
|
||||||
|
});
|
||||||
|
|
||||||
|
// return the result
|
||||||
|
return {
|
||||||
|
id: invitation.id,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.status !== 'fulfilled' || !result.value.data.success) {
|
||||||
|
logger.error(
|
||||||
|
{
|
||||||
|
...ctx,
|
||||||
|
invitationId:
|
||||||
|
result.status === 'fulfilled' ? result.value.id : result.reason,
|
||||||
|
},
|
||||||
|
'Failed to send invitation email',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const succeeded = results.filter(
|
||||||
|
(result) => result.status === 'fulfilled' && result.value.data.success,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (succeeded.length) {
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
...ctx,
|
||||||
|
count: succeeded.length,
|
||||||
|
},
|
||||||
|
'Invitation emails successfully sent!',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,175 +0,0 @@
|
|||||||
import { SupabaseClient } from '@supabase/supabase-js';
|
|
||||||
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { getLogger } from '@kit/shared/logger';
|
|
||||||
import { Database, Tables } from '@kit/supabase/database';
|
|
||||||
|
|
||||||
type Invitation = Tables<'invitations'>;
|
|
||||||
|
|
||||||
const invitePath = '/join';
|
|
||||||
|
|
||||||
const siteURL = process.env.NEXT_PUBLIC_SITE_URL;
|
|
||||||
const productName = process.env.NEXT_PUBLIC_PRODUCT_NAME ?? '';
|
|
||||||
const emailSender = process.env.EMAIL_SENDER;
|
|
||||||
|
|
||||||
const env = z
|
|
||||||
.object({
|
|
||||||
invitePath: z
|
|
||||||
.string({
|
|
||||||
required_error: 'The property invitePath is required',
|
|
||||||
})
|
|
||||||
.min(1),
|
|
||||||
siteURL: z
|
|
||||||
.string({
|
|
||||||
required_error: 'NEXT_PUBLIC_SITE_URL is required',
|
|
||||||
})
|
|
||||||
.min(1),
|
|
||||||
productName: z
|
|
||||||
.string({
|
|
||||||
required_error: 'NEXT_PUBLIC_PRODUCT_NAME is required',
|
|
||||||
})
|
|
||||||
.min(1),
|
|
||||||
emailSender: z
|
|
||||||
.string({
|
|
||||||
required_error: 'EMAIL_SENDER is required',
|
|
||||||
})
|
|
||||||
.min(1),
|
|
||||||
})
|
|
||||||
.parse({
|
|
||||||
invitePath,
|
|
||||||
siteURL,
|
|
||||||
productName,
|
|
||||||
emailSender,
|
|
||||||
});
|
|
||||||
|
|
||||||
export function createAccountInvitationsWebhookService(
|
|
||||||
client: SupabaseClient<Database>,
|
|
||||||
) {
|
|
||||||
return new AccountInvitationsWebhookService(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
class AccountInvitationsWebhookService {
|
|
||||||
private namespace = 'accounts.invitations.webhook';
|
|
||||||
|
|
||||||
constructor(private readonly adminClient: SupabaseClient<Database>) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @name handleInvitationWebhook
|
|
||||||
* @description Handles the webhook event for invitations
|
|
||||||
* @param invitation
|
|
||||||
*/
|
|
||||||
async handleInvitationWebhook(invitation: Invitation) {
|
|
||||||
return this.dispatchInvitationEmail(invitation);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async dispatchInvitationEmail(invitation: Invitation) {
|
|
||||||
const logger = await getLogger();
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
{ invitation, name: this.namespace },
|
|
||||||
'Handling invitation webhook event...',
|
|
||||||
);
|
|
||||||
|
|
||||||
const inviter = await this.adminClient
|
|
||||||
.from('accounts')
|
|
||||||
.select('email, name')
|
|
||||||
.eq('id', invitation.invited_by)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (inviter.error) {
|
|
||||||
logger.error(
|
|
||||||
{
|
|
||||||
error: inviter.error,
|
|
||||||
name: this.namespace,
|
|
||||||
},
|
|
||||||
'Failed to fetch inviter details',
|
|
||||||
);
|
|
||||||
|
|
||||||
throw inviter.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const team = await this.adminClient
|
|
||||||
.from('accounts')
|
|
||||||
.select('name')
|
|
||||||
.eq('id', invitation.account_id)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (team.error) {
|
|
||||||
logger.error(
|
|
||||||
{
|
|
||||||
error: team.error,
|
|
||||||
name: this.namespace,
|
|
||||||
},
|
|
||||||
'Failed to fetch team details',
|
|
||||||
);
|
|
||||||
|
|
||||||
throw team.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctx = {
|
|
||||||
invitationId: invitation.id,
|
|
||||||
name: this.namespace,
|
|
||||||
};
|
|
||||||
|
|
||||||
logger.info(ctx, 'Invite retrieved. Sending invitation email...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { renderInviteEmail } = await import('@kit/email-templates');
|
|
||||||
const { getMailer } = await import('@kit/mailers');
|
|
||||||
|
|
||||||
const mailer = await getMailer();
|
|
||||||
const link = this.getInvitationLink(
|
|
||||||
invitation.invite_token,
|
|
||||||
invitation.email,
|
|
||||||
);
|
|
||||||
|
|
||||||
const { html, subject } = await renderInviteEmail({
|
|
||||||
link,
|
|
||||||
invitedUserEmail: invitation.email,
|
|
||||||
inviter: inviter.data.name ?? inviter.data.email ?? '',
|
|
||||||
productName: env.productName,
|
|
||||||
teamName: team.data.name,
|
|
||||||
});
|
|
||||||
|
|
||||||
await mailer
|
|
||||||
.sendEmail({
|
|
||||||
from: env.emailSender,
|
|
||||||
to: invitation.email,
|
|
||||||
subject,
|
|
||||||
html,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
logger.info(ctx, 'Invitation email successfully sent!');
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
|
|
||||||
logger.error({ error, ...ctx }, 'Failed to send invitation email');
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
logger.warn({ error, ...ctx }, 'Failed to invite user to team');
|
|
||||||
|
|
||||||
return {
|
|
||||||
error,
|
|
||||||
success: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private getInvitationLink(token: string, email: string) {
|
|
||||||
const searchParams = new URLSearchParams({
|
|
||||||
invite_token: token,
|
|
||||||
email,
|
|
||||||
}).toString();
|
|
||||||
|
|
||||||
const href = new URL(env.invitePath, env.siteURL).href;
|
|
||||||
|
|
||||||
return `${href}?${searchParams}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { getLogger } from '@kit/shared/logger';
|
|
||||||
import { Tables } from '@kit/supabase/database';
|
|
||||||
|
|
||||||
type Account = Tables<'accounts'>;
|
|
||||||
|
|
||||||
export function createAccountWebhooksService() {
|
|
||||||
return new AccountWebhooksService();
|
|
||||||
}
|
|
||||||
|
|
||||||
class AccountWebhooksService {
|
|
||||||
private readonly namespace = 'accounts.webhooks';
|
|
||||||
|
|
||||||
async handleAccountDeletedWebhook(account: Account) {
|
|
||||||
const logger = await getLogger();
|
|
||||||
|
|
||||||
const ctx = {
|
|
||||||
accountId: account.id,
|
|
||||||
namespace: this.namespace,
|
|
||||||
};
|
|
||||||
|
|
||||||
logger.info(ctx, 'Received account deleted webhook. Processing...');
|
|
||||||
|
|
||||||
if (account.is_personal_account) {
|
|
||||||
logger.info(ctx, `Account is personal. We send an email to the user.`);
|
|
||||||
|
|
||||||
await this.sendDeleteAccountEmail(account);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async sendDeleteAccountEmail(account: Account) {
|
|
||||||
const userEmail = account.email;
|
|
||||||
const userDisplayName = account.name ?? userEmail;
|
|
||||||
|
|
||||||
const emailSettings = this.getEmailSettings();
|
|
||||||
|
|
||||||
if (userEmail) {
|
|
||||||
await this.sendAccountDeletionEmail({
|
|
||||||
fromEmail: emailSettings.fromEmail,
|
|
||||||
productName: emailSettings.productName,
|
|
||||||
userDisplayName,
|
|
||||||
userEmail,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async sendAccountDeletionEmail(params: {
|
|
||||||
fromEmail: string;
|
|
||||||
userEmail: string;
|
|
||||||
userDisplayName: string;
|
|
||||||
productName: string;
|
|
||||||
}) {
|
|
||||||
const { renderAccountDeleteEmail } = await import('@kit/email-templates');
|
|
||||||
const { getMailer } = await import('@kit/mailers');
|
|
||||||
|
|
||||||
const mailer = await getMailer();
|
|
||||||
|
|
||||||
const { html, subject } = await renderAccountDeleteEmail({
|
|
||||||
userDisplayName: params.userDisplayName,
|
|
||||||
productName: params.productName,
|
|
||||||
});
|
|
||||||
|
|
||||||
return mailer.sendEmail({
|
|
||||||
to: params.userEmail,
|
|
||||||
from: params.fromEmail,
|
|
||||||
subject,
|
|
||||||
html,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private getEmailSettings() {
|
|
||||||
const productName = process.env.NEXT_PUBLIC_PRODUCT_NAME;
|
|
||||||
const fromEmail = process.env.EMAIL_SENDER;
|
|
||||||
|
|
||||||
return z
|
|
||||||
.object({
|
|
||||||
productName: z.string(),
|
|
||||||
fromEmail: z
|
|
||||||
.string({
|
|
||||||
required_error: 'EMAIL_SENDER is required',
|
|
||||||
})
|
|
||||||
.min(1),
|
|
||||||
})
|
|
||||||
.parse({
|
|
||||||
productName,
|
|
||||||
fromEmail,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './account-webhooks.service';
|
|
||||||
export * from './account-invitations-webhook.service';
|
|
||||||
@@ -49,26 +49,26 @@ class AuthCallbackService {
|
|||||||
|
|
||||||
const token_hash = searchParams.get('token_hash');
|
const token_hash = searchParams.get('token_hash');
|
||||||
const type = searchParams.get('type') as EmailOtpType | null;
|
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;
|
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 we have a valid redirect destination
|
||||||
if (callbackUrl) {
|
if (redirectInfo) {
|
||||||
// if we have a callback url, we check if it has a next path
|
nextPath = redirectInfo.path;
|
||||||
const callbackNextPath = callbackUrl.searchParams.get('next');
|
|
||||||
|
|
||||||
// if we have a next path in the callback url, we use that
|
// preserve any query params from the redirect URL (e.g., invite_token)
|
||||||
if (callbackNextPath) {
|
// but exclude 'next' to avoid duplication
|
||||||
nextPath = callbackNextPath;
|
redirectInfo.params.forEach((value, key) => {
|
||||||
} else {
|
if (key !== 'next') {
|
||||||
nextPath = callbackUrl.pathname;
|
url.searchParams.set(key, value);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const inviteToken = callbackUrl?.searchParams.get('invite_token');
|
|
||||||
const errorPath = params.errorPath ?? '/auth/callback/error';
|
const errorPath = params.errorPath ?? '/auth/callback/error';
|
||||||
|
|
||||||
// remove the query params from the url
|
// remove the query params from the url
|
||||||
@@ -81,22 +81,6 @@ class AuthCallbackService {
|
|||||||
url.pathname = nextPath;
|
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) {
|
if (token_hash && type) {
|
||||||
const { error } = await this.client.auth.verifyOtp({
|
const { error } = await this.client.auth.verifyOtp({
|
||||||
type,
|
type,
|
||||||
@@ -147,25 +131,9 @@ class AuthCallbackService {
|
|||||||
const authCode = searchParams.get('code');
|
const authCode = searchParams.get('code');
|
||||||
const error = searchParams.get('error');
|
const error = searchParams.get('error');
|
||||||
const nextUrlPathFromParams = searchParams.get('next');
|
const nextUrlPathFromParams = searchParams.get('next');
|
||||||
const inviteToken = searchParams.get('invite_token');
|
|
||||||
const errorPath = params.errorPath ?? '/auth/callback/error';
|
const errorPath = params.errorPath ?? '/auth/callback/error';
|
||||||
|
|
||||||
let nextUrl = nextUrlPathFromParams ?? params.redirectPath;
|
const 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()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (authCode) {
|
if (authCode) {
|
||||||
try {
|
try {
|
||||||
@@ -212,12 +180,49 @@ class AuthCallbackService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private adjustUrlHostForLocalDevelopment(url: URL, host: string | null) {
|
private adjustUrlHostForLocalDevelopment(url: URL, host: string | null) {
|
||||||
if (this.isLocalhost(url.host) && !this.isLocalhost(host)) {
|
if (host && this.isLocalhost(url.host) && !this.isLocalhost(host)) {
|
||||||
url.host = host as string;
|
url.host = host;
|
||||||
url.port = '';
|
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) {
|
private isLocalhost(host: string | null) {
|
||||||
if (!host) {
|
if (!host) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -90,6 +90,11 @@
|
|||||||
"./skeleton": "./src/shadcn/skeleton.tsx",
|
"./skeleton": "./src/shadcn/skeleton.tsx",
|
||||||
"./shadcn-sidebar": "./src/shadcn/sidebar.tsx",
|
"./shadcn-sidebar": "./src/shadcn/sidebar.tsx",
|
||||||
"./collapsible": "./src/shadcn/collapsible.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",
|
"./utils": "./src/lib/utils/index.ts",
|
||||||
"./if": "./src/makerkit/if.tsx",
|
"./if": "./src/makerkit/if.tsx",
|
||||||
"./trans": "./src/makerkit/trans.tsx",
|
"./trans": "./src/makerkit/trans.tsx",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import { VariantProps, cva } from 'class-variance-authority';
|
||||||
|
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
import { Button } from '../shadcn/button';
|
import { Button } from '../shadcn/button';
|
||||||
|
|
||||||
@@ -8,7 +10,7 @@ const EmptyStateHeading: React.FC<React.HTMLAttributes<HTMLHeadingElement>> = ({
|
|||||||
...props
|
...props
|
||||||
}) => (
|
}) => (
|
||||||
<h3
|
<h3
|
||||||
className={cn('text-2xl font-bold tracking-tight', className)}
|
className={cn('text-lg font-medium tracking-tight', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -49,7 +51,16 @@ const EmptyState: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
|||||||
(child) => React.isValidElement(child) && child.type === EmptyStateButton,
|
(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(
|
const otherChildren = childrenArray.filter(
|
||||||
(child) =>
|
(child) =>
|
||||||
@@ -66,6 +77,7 @@ const EmptyState: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center gap-1 text-center">
|
<div className="flex flex-col items-center gap-1 text-center">
|
||||||
|
{media}
|
||||||
{heading}
|
{heading}
|
||||||
{text}
|
{text}
|
||||||
{button}
|
{button}
|
||||||
@@ -76,4 +88,40 @@ const EmptyState: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
|||||||
};
|
};
|
||||||
EmptyState.displayName = 'EmptyState';
|
EmptyState.displayName = 'EmptyState';
|
||||||
|
|
||||||
export { EmptyState, EmptyStateHeading, EmptyStateText, EmptyStateButton };
|
const emptyMediaVariants = cva(
|
||||||
|
'mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-transparent',
|
||||||
|
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function EmptyMedia({
|
||||||
|
className,
|
||||||
|
variant = 'default',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & VariantProps<typeof emptyMediaVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-icon"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(emptyMediaVariants({ variant, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
EmptyState,
|
||||||
|
EmptyStateHeading,
|
||||||
|
EmptyStateText,
|
||||||
|
EmptyStateButton,
|
||||||
|
EmptyMedia,
|
||||||
|
};
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function GlobalLoader({
|
|||||||
<If condition={displaySpinner}>
|
<If condition={displaySpinner}>
|
||||||
<div
|
<div
|
||||||
className={
|
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} />
|
<LoadingOverlay displayLogo={displayLogo} fullPage={fullPage} />
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ export function OauthProviderLogoImage({
|
|||||||
|
|
||||||
function getOAuthProviderLogos(): Record<string, string | React.ReactNode> {
|
function getOAuthProviderLogos(): Record<string, string | React.ReactNode> {
|
||||||
return {
|
return {
|
||||||
email: <AtSign className={'size-[18px]'} />,
|
email: <AtSign className={'size-[16px]'} />,
|
||||||
phone: <Phone className={'size-[18x]'} />,
|
phone: <Phone className={'size-[16px]'} />,
|
||||||
google: '/images/oauth/google.webp',
|
google: '/images/oauth/google.webp',
|
||||||
facebook: '/images/oauth/facebook.webp',
|
facebook: '/images/oauth/facebook.webp',
|
||||||
github: '/images/oauth/github.webp',
|
github: '/images/oauth/github.webp',
|
||||||
|
|||||||
@@ -1,36 +1,16 @@
|
|||||||
import { cn } from '../lib/utils';
|
import { Loader2Icon } from 'lucide-react';
|
||||||
|
|
||||||
export function Spinner(
|
import { cn } from '../lib/utils/cn';
|
||||||
props: React.PropsWithChildren<{
|
|
||||||
className?: string;
|
function Spinner({ className, ...props }: React.ComponentProps<'svg'>) {
|
||||||
}>,
|
|
||||||
) {
|
|
||||||
return (
|
return (
|
||||||
<div role="status" aria-label="loading">
|
<Loader2Icon
|
||||||
<svg
|
role="status"
|
||||||
className={cn(
|
aria-label="Loading"
|
||||||
props.className,
|
className={cn('text-muted-foreground size-6 animate-spin', className)}
|
||||||
'stroke-muted-foreground h-6 w-6 animate-spin',
|
{...props}
|
||||||
)}
|
/>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { Spinner };
|
||||||
|
|||||||
83
packages/ui/src/shadcn/button-group.tsx
Normal file
83
packages/ui/src/shadcn/button-group.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { type VariantProps, cva } from 'class-variance-authority';
|
||||||
|
import { Slot } from 'radix-ui';
|
||||||
|
|
||||||
|
import { cn } from '../lib/utils/cn';
|
||||||
|
import { Separator } from './separator';
|
||||||
|
|
||||||
|
const buttonGroupVariants = cva(
|
||||||
|
"flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
orientation: {
|
||||||
|
horizontal:
|
||||||
|
'[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none',
|
||||||
|
vertical:
|
||||||
|
'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
orientation: 'horizontal',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function ButtonGroup({
|
||||||
|
className,
|
||||||
|
orientation,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & VariantProps<typeof buttonGroupVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="button-group"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ButtonGroupText({
|
||||||
|
className,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot.Root : 'div';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(
|
||||||
|
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ButtonGroupSeparator({
|
||||||
|
className,
|
||||||
|
orientation = 'vertical',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Separator>) {
|
||||||
|
return (
|
||||||
|
<Separator
|
||||||
|
data-slot="button-group-separator"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ButtonGroup,
|
||||||
|
ButtonGroupSeparator,
|
||||||
|
ButtonGroupText,
|
||||||
|
buttonGroupVariants,
|
||||||
|
};
|
||||||
245
packages/ui/src/shadcn/field.tsx
Normal file
245
packages/ui/src/shadcn/field.tsx
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { type VariantProps, cva } from 'class-variance-authority';
|
||||||
|
|
||||||
|
import { cn } from '../lib/utils/cn';
|
||||||
|
import { Label } from './label';
|
||||||
|
import { Separator } from './separator';
|
||||||
|
|
||||||
|
function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) {
|
||||||
|
return (
|
||||||
|
<fieldset
|
||||||
|
data-slot="field-set"
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col gap-6',
|
||||||
|
'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldLegend({
|
||||||
|
className,
|
||||||
|
variant = 'legend',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) {
|
||||||
|
return (
|
||||||
|
<legend
|
||||||
|
data-slot="field-legend"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
'mb-3 font-medium',
|
||||||
|
'data-[variant=legend]:text-base',
|
||||||
|
'data-[variant=label]:text-sm',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-group"
|
||||||
|
className={cn(
|
||||||
|
'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldVariants = cva(
|
||||||
|
'group/field data-[invalid=true]:text-destructive flex w-full gap-3',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
orientation: {
|
||||||
|
vertical: ['flex-col [&>*]:w-full [&>.sr-only]:w-auto'],
|
||||||
|
horizontal: [
|
||||||
|
'flex-row items-center',
|
||||||
|
'[&>[data-slot=field-label]]:flex-auto',
|
||||||
|
'has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
|
||||||
|
],
|
||||||
|
responsive: [
|
||||||
|
'flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto',
|
||||||
|
'@md/field-group:[&>[data-slot=field-label]]:flex-auto',
|
||||||
|
'@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
orientation: 'vertical',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
className,
|
||||||
|
orientation = 'vertical',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & VariantProps<typeof fieldVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="field"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(fieldVariants({ orientation }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-content"
|
||||||
|
className={cn(
|
||||||
|
'group/field-content flex flex-1 flex-col gap-1.5 leading-snug',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Label>) {
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
data-slot="field-label"
|
||||||
|
className={cn(
|
||||||
|
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
|
||||||
|
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4',
|
||||||
|
'has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-label"
|
||||||
|
className={cn(
|
||||||
|
'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="field-description"
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance',
|
||||||
|
'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5',
|
||||||
|
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-separator"
|
||||||
|
data-content={!!children}
|
||||||
|
className={cn(
|
||||||
|
'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Separator className="absolute inset-0 top-1/2" />
|
||||||
|
{children && (
|
||||||
|
<span
|
||||||
|
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
|
||||||
|
data-slot="field-separator-content"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldError({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
errors,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & {
|
||||||
|
errors?: Array<{ message?: string } | undefined>;
|
||||||
|
}) {
|
||||||
|
const content = useMemo(() => {
|
||||||
|
if (children) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!errors) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors?.length === 1 && errors[0]?.message) {
|
||||||
|
return errors[0].message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||||
|
{errors.map(
|
||||||
|
(error, index) =>
|
||||||
|
error?.message && <li key={index}>{error.message}</li>,
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}, [children, errors]);
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
data-slot="field-error"
|
||||||
|
className={cn('text-destructive text-sm font-normal', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Field,
|
||||||
|
FieldLabel,
|
||||||
|
FieldDescription,
|
||||||
|
FieldError,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSeparator,
|
||||||
|
FieldSet,
|
||||||
|
FieldContent,
|
||||||
|
FieldTitle,
|
||||||
|
};
|
||||||
171
packages/ui/src/shadcn/input-group.tsx
Normal file
171
packages/ui/src/shadcn/input-group.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { type VariantProps, cva } from 'class-variance-authority';
|
||||||
|
|
||||||
|
import { cn } from '../lib/utils/cn';
|
||||||
|
import { Button } from '../shadcn/button';
|
||||||
|
import { Input } from '../shadcn/input';
|
||||||
|
import { Textarea } from '../shadcn/textarea';
|
||||||
|
|
||||||
|
function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-group"
|
||||||
|
role="group"
|
||||||
|
className={cn(
|
||||||
|
'group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none',
|
||||||
|
'h-9 has-[>textarea]:h-auto',
|
||||||
|
|
||||||
|
// Variants based on alignment.
|
||||||
|
'has-[>[data-align=inline-start]]:[&>input]:pl-2',
|
||||||
|
'has-[>[data-align=inline-end]]:[&>input]:pr-2',
|
||||||
|
'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3',
|
||||||
|
'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',
|
||||||
|
|
||||||
|
// Focus state.
|
||||||
|
'has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]',
|
||||||
|
|
||||||
|
// Error state.
|
||||||
|
'has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40',
|
||||||
|
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputGroupAddonVariants = cva(
|
||||||
|
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
align: {
|
||||||
|
'inline-start':
|
||||||
|
'order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]',
|
||||||
|
'inline-end':
|
||||||
|
'order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]',
|
||||||
|
'block-start':
|
||||||
|
'order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3',
|
||||||
|
'block-end':
|
||||||
|
'order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5 [.border-t]:pt-3',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
align: 'inline-start',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function InputGroupAddon({
|
||||||
|
className,
|
||||||
|
align = 'inline-start',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="input-group-addon"
|
||||||
|
data-align={align}
|
||||||
|
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||||
|
onClick={(e) => {
|
||||||
|
if ((e.target as HTMLElement).closest('button')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.currentTarget.parentElement?.querySelector('input')?.focus();
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputGroupButtonVariants = cva(
|
||||||
|
'flex items-center gap-2 text-sm shadow-none',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
|
||||||
|
sm: 'h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5',
|
||||||
|
'icon-xs':
|
||||||
|
'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0',
|
||||||
|
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: 'xs',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function InputGroupButton({
|
||||||
|
className,
|
||||||
|
type = 'button',
|
||||||
|
variant = 'ghost',
|
||||||
|
size = 'xs',
|
||||||
|
...props
|
||||||
|
}: Omit<React.ComponentProps<typeof Button>, 'size'> &
|
||||||
|
VariantProps<typeof inputGroupButtonVariants>) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type={type}
|
||||||
|
data-size={size}
|
||||||
|
variant={variant}
|
||||||
|
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'input'>) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
data-slot="input-group-control"
|
||||||
|
className={cn(
|
||||||
|
'flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupTextarea({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'textarea'>) {
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
data-slot="input-group-control"
|
||||||
|
className={cn(
|
||||||
|
'flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupButton,
|
||||||
|
InputGroupText,
|
||||||
|
InputGroupInput,
|
||||||
|
InputGroupTextarea,
|
||||||
|
};
|
||||||
195
packages/ui/src/shadcn/item.tsx
Normal file
195
packages/ui/src/shadcn/item.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { type VariantProps, cva } from 'class-variance-authority';
|
||||||
|
import { Slot } from 'radix-ui';
|
||||||
|
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
import { Separator } from './separator';
|
||||||
|
|
||||||
|
function ItemGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="list"
|
||||||
|
data-slot="item-group"
|
||||||
|
className={cn('group/item-group flex flex-col', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Separator>) {
|
||||||
|
return (
|
||||||
|
<Separator
|
||||||
|
data-slot="item-separator"
|
||||||
|
orientation="horizontal"
|
||||||
|
className={cn('my-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemVariants = cva(
|
||||||
|
'group/item [a]:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-ring/50 flex flex-wrap items-center rounded-md border border-transparent text-sm transition-colors duration-100 outline-none focus-visible:ring-[3px] [a]:transition-colors',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-transparent',
|
||||||
|
outline: 'border-border',
|
||||||
|
muted: 'bg-muted/50',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'gap-4 p-4',
|
||||||
|
sm: 'gap-2.5 px-4 py-3',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function Item({
|
||||||
|
className,
|
||||||
|
variant = 'default',
|
||||||
|
size = 'default',
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> &
|
||||||
|
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot.Root : 'div';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="item"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(itemVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemMediaVariants = cva(
|
||||||
|
'flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:translate-y-0.5 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-transparent',
|
||||||
|
icon: "bg-muted size-8 rounded-sm border [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
image:
|
||||||
|
'size-10 overflow-hidden rounded-sm [&_img]:size-full [&_img]:object-cover',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function ItemMedia({
|
||||||
|
className,
|
||||||
|
variant = 'default',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & VariantProps<typeof itemMediaVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-media"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(itemMediaVariants({ variant, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-content"
|
||||||
|
className={cn(
|
||||||
|
'flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-title"
|
||||||
|
className={cn(
|
||||||
|
'flex w-fit items-center gap-2 text-sm leading-snug font-medium',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="item-description"
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance',
|
||||||
|
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemActions({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-actions"
|
||||||
|
className={cn('flex items-center gap-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-header"
|
||||||
|
className={cn(
|
||||||
|
'flex basis-full items-center justify-between gap-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-footer"
|
||||||
|
className={cn(
|
||||||
|
'flex basis-full items-center justify-between gap-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Item,
|
||||||
|
ItemMedia,
|
||||||
|
ItemContent,
|
||||||
|
ItemActions,
|
||||||
|
ItemGroup,
|
||||||
|
ItemSeparator,
|
||||||
|
ItemTitle,
|
||||||
|
ItemDescription,
|
||||||
|
ItemHeader,
|
||||||
|
ItemFooter,
|
||||||
|
};
|
||||||
28
packages/ui/src/shadcn/kbd.tsx
Normal file
28
packages/ui/src/shadcn/kbd.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { cn } from '../lib/utils/cn';
|
||||||
|
|
||||||
|
function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) {
|
||||||
|
return (
|
||||||
|
<kbd
|
||||||
|
data-slot="kbd"
|
||||||
|
className={cn(
|
||||||
|
'bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none',
|
||||||
|
"[&_svg:not([class*='size-'])]:size-3",
|
||||||
|
'[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KbdGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<kbd
|
||||||
|
data-slot="kbd-group"
|
||||||
|
className={cn('inline-flex items-center gap-1', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Kbd, KbdGroup };
|
||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -634,9 +634,6 @@ importers:
|
|||||||
'@kit/supabase':
|
'@kit/supabase':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../supabase
|
version: link:../supabase
|
||||||
'@kit/team-accounts':
|
|
||||||
specifier: workspace:*
|
|
||||||
version: link:../features/team-accounts
|
|
||||||
'@kit/tsconfig':
|
'@kit/tsconfig':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../tooling/typescript
|
version: link:../../tooling/typescript
|
||||||
|
|||||||
Reference in New Issue
Block a user