@startsimpli/ui 0.4.14 → 0.4.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +457 -398
- package/package.json +18 -13
- package/src/components/__tests__/calendar-view-popup.test.tsx +42 -0
- package/src/components/__tests__/chat.test.tsx +129 -0
- package/src/components/__tests__/meetings-list.test.tsx +114 -0
- package/src/components/__tests__/slide-deck-viewer.test.tsx +82 -0
- package/src/components/__tests__/workspace.test.tsx +106 -0
- package/src/components/account/__tests__/account.test.tsx +5 -32
- package/src/components/account/change-password-form.tsx +1 -28
- package/src/components/calendar/calendar-view.tsx +31 -0
- package/src/components/calendar/index.ts +7 -0
- package/src/components/calendar/meetings-list.tsx +202 -0
- package/src/components/calendar/upcoming-meetings.tsx +5 -5
- package/src/components/chat/ChatComposer.tsx +113 -0
- package/src/components/chat/ChatMessage.tsx +81 -0
- package/src/components/chat/ChatThread.tsx +57 -0
- package/src/components/chat/index.ts +12 -0
- package/src/components/chat/types.ts +20 -0
- package/src/components/index.ts +13 -0
- package/src/components/slide-deck/SlideCanvas.tsx +68 -0
- package/src/components/slide-deck/SlideDeckViewer.tsx +144 -0
- package/src/components/slide-deck/SlideFilmstrip.tsx +73 -0
- package/src/components/slide-deck/index.ts +7 -0
- package/src/components/slide-deck/types.ts +18 -0
- package/src/components/team/DomainClaimCard.tsx +170 -0
- package/src/components/team/InviteMemberDialog.tsx +182 -0
- package/src/components/team/LeaveTeamDialog.tsx +130 -0
- package/src/components/team/MembersTable.tsx +138 -0
- package/src/components/team/OrgSwitcher.tsx +68 -0
- package/src/components/team/PendingInvitationCallout.tsx +106 -0
- package/src/components/team/RoleSelector.tsx +68 -0
- package/src/components/team/__tests__/team-components.test.tsx +352 -0
- package/src/components/team/domain-claim-card-default-class-names.ts +45 -0
- package/src/components/team/index.ts +57 -0
- package/src/components/team/invite-member-dialog-default-class-names.ts +41 -0
- package/src/components/team/leave-team-dialog-default-class-names.ts +33 -0
- package/src/components/team/members-table-default-class-names.ts +39 -0
- package/src/components/team/org-switcher-default-class-names.ts +13 -0
- package/src/components/team/pending-invitation-callout-default-class-names.ts +22 -0
- package/src/components/team/role-selector-default-class-names.ts +11 -0
- package/src/components/team/types.ts +97 -0
- package/src/components/workflows/ExecNodeDetails.tsx +83 -0
- package/src/components/workflows/ExecutionTimeline.tsx +146 -0
- package/src/components/workflows/NodeInspector.tsx +257 -0
- package/src/components/workflows/NodePalette.tsx +119 -0
- package/src/components/workflows/WorkflowCanvas.tsx +113 -0
- package/src/components/workflows/WorkflowEdge.tsx +65 -0
- package/src/components/workflows/WorkflowEditor.tsx +130 -0
- package/src/components/workflows/WorkflowNode.tsx +198 -0
- package/src/components/workflows/WorkflowRunViewer.tsx +81 -0
- package/src/components/workflows/__tests__/ExecutionTimeline.test.tsx +99 -0
- package/src/components/workflows/__tests__/NodeInspector.test.tsx +74 -0
- package/src/components/workflows/__tests__/NodePalette.test.tsx +46 -0
- package/src/components/workflows/__tests__/WorkflowCanvas.test.tsx +59 -0
- package/src/components/workflows/__tests__/WorkflowNode.test.tsx +92 -0
- package/src/components/workflows/__tests__/WorkflowRunViewer.test.tsx +138 -0
- package/src/components/workflows/__tests__/auto-layout.test.ts +107 -0
- package/src/components/workflows/__tests__/serialization.test.ts +278 -0
- package/src/components/workflows/exec-status.ts +90 -0
- package/src/components/workflows/hooks/useCanvasGraph.ts +70 -0
- package/src/components/workflows/hooks/useNodeStatusOverlay.ts +47 -0
- package/src/components/workflows/index.ts +78 -0
- package/src/components/workflows/layout/auto-layout.ts +142 -0
- package/src/components/workflows/node-icons.ts +31 -0
- package/src/components/workflows/serialization.ts +171 -0
- package/src/components/workflows/theme/categories.ts +96 -0
- package/src/components/workflows/types.ts +231 -0
- package/src/components/workflows/workflows.css +29 -0
- package/src/components/workspace/DualPaneWorkspace.tsx +187 -0
- package/src/components/workspace/SplitPane.tsx +174 -0
- package/src/components/workspace/index.ts +4 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { ImageOff, Loader2, RefreshCw } from 'lucide-react'
|
|
5
|
+
import { cn } from '../../lib/utils'
|
|
6
|
+
import type { SlideData } from './types'
|
|
7
|
+
|
|
8
|
+
export interface SlideCanvasProps {
|
|
9
|
+
slide: SlideData
|
|
10
|
+
/** Retry/regenerate handler shown in the error state. */
|
|
11
|
+
onRetry?: () => void
|
|
12
|
+
/** Render structured slides (future); falls back to content text for now. */
|
|
13
|
+
renderStructured?: (slide: SlideData) => React.ReactNode
|
|
14
|
+
className?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Renders a single slide at a fixed 16:9 aspect ratio. Image render mode shows
|
|
19
|
+
* the generated full-bleed image with loading / error states; structured mode
|
|
20
|
+
* delegates to `renderStructured`. App-agnostic.
|
|
21
|
+
*/
|
|
22
|
+
export function SlideCanvas({ slide, onRetry, renderStructured, className }: SlideCanvasProps) {
|
|
23
|
+
const status = slide.status ?? (slide.imageUrl ? 'ready' : 'pending')
|
|
24
|
+
const mode = slide.renderMode ?? 'image'
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div
|
|
28
|
+
className={cn(
|
|
29
|
+
'relative mx-auto aspect-video w-full max-w-5xl overflow-hidden rounded-lg border border-border bg-white shadow-sm',
|
|
30
|
+
className
|
|
31
|
+
)}
|
|
32
|
+
data-status={status}
|
|
33
|
+
aria-busy={status === 'generating' || undefined}
|
|
34
|
+
>
|
|
35
|
+
{mode === 'structured' && renderStructured ? (
|
|
36
|
+
renderStructured(slide)
|
|
37
|
+
) : status === 'ready' && slide.imageUrl ? (
|
|
38
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
39
|
+
<img
|
|
40
|
+
src={slide.imageUrl}
|
|
41
|
+
alt={slide.alt ?? slide.title ?? `Slide ${slide.slideNumber}`}
|
|
42
|
+
className="h-full w-full object-contain"
|
|
43
|
+
/>
|
|
44
|
+
) : status === 'error' ? (
|
|
45
|
+
<div className="flex h-full w-full flex-col items-center justify-center gap-3 text-muted-foreground">
|
|
46
|
+
<ImageOff className="h-8 w-8" aria-hidden="true" />
|
|
47
|
+
<p className="text-sm">This slide failed to generate.</p>
|
|
48
|
+
{onRetry && (
|
|
49
|
+
<button
|
|
50
|
+
type="button"
|
|
51
|
+
onClick={onRetry}
|
|
52
|
+
className="inline-flex items-center gap-1.5 rounded-md border border-input bg-background px-3 py-1.5 text-sm shadow-sm hover:bg-accent"
|
|
53
|
+
>
|
|
54
|
+
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" /> Retry
|
|
55
|
+
</button>
|
|
56
|
+
)}
|
|
57
|
+
</div>
|
|
58
|
+
) : (
|
|
59
|
+
<div className="flex h-full w-full flex-col items-center justify-center gap-3 text-muted-foreground">
|
|
60
|
+
<Loader2 className={cn('h-8 w-8', status === 'generating' && 'animate-spin')} aria-hidden="true" />
|
|
61
|
+
<p className="text-sm">
|
|
62
|
+
{status === 'generating' ? `Generating slide ${slide.slideNumber}…` : 'Waiting to generate…'}
|
|
63
|
+
</p>
|
|
64
|
+
</div>
|
|
65
|
+
)}
|
|
66
|
+
</div>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react'
|
|
5
|
+
import { cn } from '../../lib/utils'
|
|
6
|
+
import { SlideCanvas } from './SlideCanvas'
|
|
7
|
+
import { SlideFilmstrip } from './SlideFilmstrip'
|
|
8
|
+
import type { SlideData } from './types'
|
|
9
|
+
|
|
10
|
+
export interface SlideDeckViewerProps {
|
|
11
|
+
slides: SlideData[]
|
|
12
|
+
/** Controlled active slide id. Omit for uncontrolled (defaults to first). */
|
|
13
|
+
activeId?: string
|
|
14
|
+
onActiveChange?: (id: string) => void
|
|
15
|
+
/** Regenerate the currently-active slide. */
|
|
16
|
+
onRegenerate?: (slide: SlideData) => void
|
|
17
|
+
emptyState?: React.ReactNode
|
|
18
|
+
/** Extra actions rendered in the stage toolbar (e.g. approve / edit). */
|
|
19
|
+
toolbarActions?: (slide: SlideData) => React.ReactNode
|
|
20
|
+
renderStructured?: (slide: SlideData) => React.ReactNode
|
|
21
|
+
className?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Google-Slides-style deck viewer: a large active-slide stage with prev/next +
|
|
26
|
+
* regenerate controls, a position indicator, and a thumbnail filmstrip.
|
|
27
|
+
* Keyboard arrows navigate. App-agnostic — `present-web` supplies the slides.
|
|
28
|
+
*/
|
|
29
|
+
export function SlideDeckViewer({
|
|
30
|
+
slides,
|
|
31
|
+
activeId,
|
|
32
|
+
onActiveChange,
|
|
33
|
+
onRegenerate,
|
|
34
|
+
emptyState,
|
|
35
|
+
toolbarActions,
|
|
36
|
+
renderStructured,
|
|
37
|
+
className,
|
|
38
|
+
}: SlideDeckViewerProps) {
|
|
39
|
+
const isControlled = activeId !== undefined
|
|
40
|
+
const [internalId, setInternalId] = React.useState<string | undefined>(slides[0]?.id)
|
|
41
|
+
|
|
42
|
+
const currentId = isControlled ? activeId : internalId
|
|
43
|
+
const index = Math.max(0, slides.findIndex((s) => s.id === currentId))
|
|
44
|
+
const active = slides[index]
|
|
45
|
+
|
|
46
|
+
const goTo = React.useCallback(
|
|
47
|
+
(i: number) => {
|
|
48
|
+
const clamped = Math.min(slides.length - 1, Math.max(0, i))
|
|
49
|
+
const next = slides[clamped]
|
|
50
|
+
if (!next) return
|
|
51
|
+
if (!isControlled) setInternalId(next.id)
|
|
52
|
+
onActiveChange?.(next.id)
|
|
53
|
+
},
|
|
54
|
+
[slides, isControlled, onActiveChange]
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
const onKeyDown = (e: React.KeyboardEvent) => {
|
|
58
|
+
if (e.key === 'ArrowRight') {
|
|
59
|
+
e.preventDefault()
|
|
60
|
+
goTo(index + 1)
|
|
61
|
+
} else if (e.key === 'ArrowLeft') {
|
|
62
|
+
e.preventDefault()
|
|
63
|
+
goTo(index - 1)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (slides.length === 0) {
|
|
68
|
+
return (
|
|
69
|
+
<div className={cn('flex h-full items-center justify-center p-6 text-center text-muted-foreground', className)}>
|
|
70
|
+
{emptyState ?? 'No slides yet.'}
|
|
71
|
+
</div>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div className={cn('flex h-full min-h-0 flex-col bg-muted/20', className)}>
|
|
77
|
+
{/* Stage toolbar */}
|
|
78
|
+
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-border bg-background/60 px-3 py-2">
|
|
79
|
+
<div className="flex items-center gap-1">
|
|
80
|
+
<button
|
|
81
|
+
type="button"
|
|
82
|
+
onClick={() => goTo(index - 1)}
|
|
83
|
+
disabled={index === 0}
|
|
84
|
+
aria-label="Previous slide"
|
|
85
|
+
className="rounded p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30"
|
|
86
|
+
>
|
|
87
|
+
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
|
|
88
|
+
</button>
|
|
89
|
+
<span className="min-w-14 text-center text-sm font-medium tabular-nums">
|
|
90
|
+
{index + 1} / {slides.length}
|
|
91
|
+
</span>
|
|
92
|
+
<button
|
|
93
|
+
type="button"
|
|
94
|
+
onClick={() => goTo(index + 1)}
|
|
95
|
+
disabled={index === slides.length - 1}
|
|
96
|
+
aria-label="Next slide"
|
|
97
|
+
className="rounded p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30"
|
|
98
|
+
>
|
|
99
|
+
<ChevronRight className="h-4 w-4" aria-hidden="true" />
|
|
100
|
+
</button>
|
|
101
|
+
</div>
|
|
102
|
+
<div className="flex items-center gap-2">
|
|
103
|
+
{active && toolbarActions?.(active)}
|
|
104
|
+
{onRegenerate && active && (
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
onClick={() => onRegenerate(active)}
|
|
108
|
+
className="inline-flex items-center gap-1.5 rounded-md border border-input bg-background px-2.5 py-1.5 text-sm shadow-sm hover:bg-accent"
|
|
109
|
+
>
|
|
110
|
+
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" /> Regenerate
|
|
111
|
+
</button>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
{/* Stage */}
|
|
117
|
+
<div
|
|
118
|
+
className="flex min-h-0 flex-1 items-center justify-center p-4 focus:outline-none"
|
|
119
|
+
data-testid="slide-deck-stage"
|
|
120
|
+
tabIndex={0}
|
|
121
|
+
role="group"
|
|
122
|
+
aria-label={`Slide ${index + 1} of ${slides.length}`}
|
|
123
|
+
onKeyDown={onKeyDown}
|
|
124
|
+
>
|
|
125
|
+
{active && (
|
|
126
|
+
<SlideCanvas
|
|
127
|
+
slide={active}
|
|
128
|
+
onRetry={onRegenerate ? () => onRegenerate(active) : undefined}
|
|
129
|
+
renderStructured={renderStructured}
|
|
130
|
+
/>
|
|
131
|
+
)}
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
{/* Filmstrip */}
|
|
135
|
+
<div className="shrink-0 border-t border-border bg-background/60">
|
|
136
|
+
<SlideFilmstrip
|
|
137
|
+
slides={slides}
|
|
138
|
+
activeId={currentId}
|
|
139
|
+
onSelect={(id) => goTo(slides.findIndex((s) => s.id === id))}
|
|
140
|
+
/>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { Loader2 } from 'lucide-react'
|
|
5
|
+
import { cn } from '../../lib/utils'
|
|
6
|
+
import type { SlideData } from './types'
|
|
7
|
+
|
|
8
|
+
export interface SlideFilmstripProps {
|
|
9
|
+
slides: SlideData[]
|
|
10
|
+
activeId?: string
|
|
11
|
+
onSelect: (id: string) => void
|
|
12
|
+
/** `horizontal` (default) for a bottom strip, `vertical` for a side rail. */
|
|
13
|
+
orientation?: 'horizontal' | 'vertical'
|
|
14
|
+
className?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Scrollable strip of slide thumbnails. Highlights the active slide and emits
|
|
19
|
+
* its id on click. App-agnostic.
|
|
20
|
+
*/
|
|
21
|
+
export function SlideFilmstrip({
|
|
22
|
+
slides,
|
|
23
|
+
activeId,
|
|
24
|
+
onSelect,
|
|
25
|
+
orientation = 'horizontal',
|
|
26
|
+
className,
|
|
27
|
+
}: SlideFilmstripProps) {
|
|
28
|
+
return (
|
|
29
|
+
<div
|
|
30
|
+
className={cn(
|
|
31
|
+
'flex gap-2 overflow-auto p-2',
|
|
32
|
+
orientation === 'horizontal' ? 'flex-row' : 'flex-col',
|
|
33
|
+
className
|
|
34
|
+
)}
|
|
35
|
+
role="group"
|
|
36
|
+
aria-label="Slides"
|
|
37
|
+
>
|
|
38
|
+
{slides.map((slide) => {
|
|
39
|
+
const active = slide.id === activeId
|
|
40
|
+
const status = slide.status ?? (slide.imageUrl ? 'ready' : 'pending')
|
|
41
|
+
return (
|
|
42
|
+
<button
|
|
43
|
+
key={slide.id}
|
|
44
|
+
type="button"
|
|
45
|
+
aria-current={active ? 'true' : undefined}
|
|
46
|
+
aria-label={`Slide ${slide.slideNumber}${slide.title ? `: ${slide.title}` : ''}`}
|
|
47
|
+
onClick={() => onSelect(slide.id)}
|
|
48
|
+
className={cn(
|
|
49
|
+
'group relative aspect-video w-28 shrink-0 overflow-hidden rounded-md border bg-white text-left transition-all',
|
|
50
|
+
active ? 'border-primary ring-2 ring-primary' : 'border-border hover:border-primary/50'
|
|
51
|
+
)}
|
|
52
|
+
>
|
|
53
|
+
<span className="absolute left-1 top-1 z-10 rounded bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white">
|
|
54
|
+
{slide.slideNumber}
|
|
55
|
+
</span>
|
|
56
|
+
{status === 'ready' && slide.imageUrl ? (
|
|
57
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
58
|
+
<img
|
|
59
|
+
src={slide.imageUrl}
|
|
60
|
+
alt={slide.title ?? `Slide ${slide.slideNumber}`}
|
|
61
|
+
className="h-full w-full object-cover"
|
|
62
|
+
/>
|
|
63
|
+
) : (
|
|
64
|
+
<div className="flex h-full w-full items-center justify-center bg-muted text-muted-foreground">
|
|
65
|
+
<Loader2 className={cn('h-4 w-4', status === 'generating' && 'animate-spin')} aria-hidden="true" />
|
|
66
|
+
</div>
|
|
67
|
+
)}
|
|
68
|
+
</button>
|
|
69
|
+
)
|
|
70
|
+
})}
|
|
71
|
+
</div>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { SlideCanvas } from './SlideCanvas'
|
|
2
|
+
export type { SlideCanvasProps } from './SlideCanvas'
|
|
3
|
+
export { SlideFilmstrip } from './SlideFilmstrip'
|
|
4
|
+
export type { SlideFilmstripProps } from './SlideFilmstrip'
|
|
5
|
+
export { SlideDeckViewer } from './SlideDeckViewer'
|
|
6
|
+
export type { SlideDeckViewerProps } from './SlideDeckViewer'
|
|
7
|
+
export type { SlideData, SlideStatus, SlideRenderMode } from './types'
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type SlideStatus = 'pending' | 'generating' | 'ready' | 'error'
|
|
2
|
+
|
|
3
|
+
export type SlideRenderMode = 'image' | 'structured'
|
|
4
|
+
|
|
5
|
+
export interface SlideData {
|
|
6
|
+
id: string
|
|
7
|
+
slideNumber: number
|
|
8
|
+
title?: string
|
|
9
|
+
/** Raw text content / outline for this slide. */
|
|
10
|
+
content?: string
|
|
11
|
+
/** Full-bleed image URL (image render mode). */
|
|
12
|
+
imageUrl?: string
|
|
13
|
+
/** Alt text for the slide image. */
|
|
14
|
+
alt?: string
|
|
15
|
+
status?: SlideStatus
|
|
16
|
+
/** `image` (default) renders the generated image; `structured` renders blocks. */
|
|
17
|
+
renderMode?: SlideRenderMode
|
|
18
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DomainClaimCard — renders one EmailDomainClaim row + drives the two
|
|
5
|
+
* verification paths (DNS TXT + email attestation).
|
|
6
|
+
*
|
|
7
|
+
* The card never knows the API directly — apps supply onVerifyDns /
|
|
8
|
+
* onVerifyEmail / onRevoke callbacks that typically wrap
|
|
9
|
+
* api.domainClaims.*. The verificationToken (creator-only, single-show) is
|
|
10
|
+
* rendered when present, with copy-to-clipboard help text. startsim-o7s.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as React from 'react'
|
|
14
|
+
import type { DomainClaimLite } from './types'
|
|
15
|
+
import {
|
|
16
|
+
DOMAIN_CLAIM_DEFAULTS,
|
|
17
|
+
type DomainClaimCardClassNames,
|
|
18
|
+
} from './domain-claim-card-default-class-names'
|
|
19
|
+
|
|
20
|
+
export interface DomainClaimCardProps {
|
|
21
|
+
claim: DomainClaimLite
|
|
22
|
+
onVerifyDns?: (claim: DomainClaimLite) => Promise<void> | void
|
|
23
|
+
onInitiateEmail?: (claim: DomainClaimLite) => Promise<void> | void
|
|
24
|
+
onVerifyEmail?: (claim: DomainClaimLite, code: string) => Promise<void> | void
|
|
25
|
+
onRevoke?: (claim: DomainClaimLite) => Promise<void> | void
|
|
26
|
+
classNames?: DomainClaimCardClassNames
|
|
27
|
+
/** Override the DNS-record name hint shown to the user. */
|
|
28
|
+
dnsRecordName?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function DomainClaimCard({
|
|
32
|
+
claim,
|
|
33
|
+
onVerifyDns,
|
|
34
|
+
onInitiateEmail,
|
|
35
|
+
onVerifyEmail,
|
|
36
|
+
onRevoke,
|
|
37
|
+
classNames,
|
|
38
|
+
dnsRecordName,
|
|
39
|
+
}: DomainClaimCardProps) {
|
|
40
|
+
const cls = { ...DOMAIN_CLAIM_DEFAULTS, ...(classNames ?? {}) }
|
|
41
|
+
const [busy, setBusy] = React.useState<'dns' | 'email-initiate' | 'email-code' | 'revoke' | null>(null)
|
|
42
|
+
const [error, setError] = React.useState('')
|
|
43
|
+
const [emailCode, setEmailCode] = React.useState('')
|
|
44
|
+
const [emailInitiated, setEmailInitiated] = React.useState(false)
|
|
45
|
+
|
|
46
|
+
function recordName() {
|
|
47
|
+
return dnsRecordName ?? `_startsim-verify.${claim.domain}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function run<T>(label: typeof busy, fn: () => Promise<T> | T) {
|
|
51
|
+
setError('')
|
|
52
|
+
setBusy(label)
|
|
53
|
+
try {
|
|
54
|
+
await fn()
|
|
55
|
+
} catch (err) {
|
|
56
|
+
setError(err instanceof Error ? err.message : 'Verification failed')
|
|
57
|
+
} finally {
|
|
58
|
+
setBusy(null)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<article className={cls.root}>
|
|
64
|
+
<div className={cls.header}>
|
|
65
|
+
<span className={cls.domain}>{claim.domain}</span>
|
|
66
|
+
<span
|
|
67
|
+
className={[
|
|
68
|
+
cls.statusBadge,
|
|
69
|
+
claim.verified ? cls.statusVerified : cls.statusUnverified,
|
|
70
|
+
].join(' ')}
|
|
71
|
+
>
|
|
72
|
+
{claim.verified ? 'Verified' : 'Pending verification'}
|
|
73
|
+
</span>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
{!claim.verified && (
|
|
77
|
+
<div className={cls.body}>
|
|
78
|
+
{claim.verificationToken && (
|
|
79
|
+
<div>
|
|
80
|
+
<p className={cls.sectionLabel}>DNS TXT verification</p>
|
|
81
|
+
<p className={cls.helpText}>
|
|
82
|
+
Add a TXT record at{' '}
|
|
83
|
+
<code>{recordName()}</code> with this value, then click
|
|
84
|
+
"Verify DNS".
|
|
85
|
+
</p>
|
|
86
|
+
<div className={cls.tokenRow}>
|
|
87
|
+
<code className={cls.tokenCode}>{claim.verificationToken}</code>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
)}
|
|
91
|
+
|
|
92
|
+
<div>
|
|
93
|
+
<p className={cls.sectionLabel}>Email attestation</p>
|
|
94
|
+
<p className={cls.helpText}>
|
|
95
|
+
We'll email <code>postmaster@{claim.domain}</code> a 6-digit
|
|
96
|
+
code. Anyone at that mailbox can confirm the claim.
|
|
97
|
+
</p>
|
|
98
|
+
{emailInitiated && (
|
|
99
|
+
<div className={cls.tokenRow}>
|
|
100
|
+
<input
|
|
101
|
+
type="text"
|
|
102
|
+
inputMode="numeric"
|
|
103
|
+
maxLength={6}
|
|
104
|
+
value={emailCode}
|
|
105
|
+
onChange={(e) => setEmailCode(e.target.value.replace(/\D/g, ''))}
|
|
106
|
+
placeholder="123456"
|
|
107
|
+
aria-label="Attestation code"
|
|
108
|
+
className={cls.codeInput}
|
|
109
|
+
/>
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
className={cls.primaryButton}
|
|
113
|
+
disabled={busy !== null || emailCode.length < 6}
|
|
114
|
+
onClick={() =>
|
|
115
|
+
run('email-code', async () => {
|
|
116
|
+
await onVerifyEmail?.(claim, emailCode)
|
|
117
|
+
setEmailCode('')
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
>
|
|
121
|
+
{busy === 'email-code' ? 'Verifying…' : 'Submit code'}
|
|
122
|
+
</button>
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
{error && <p className={cls.errorText} role="alert">{error}</p>}
|
|
128
|
+
</div>
|
|
129
|
+
)}
|
|
130
|
+
|
|
131
|
+
<div className={cls.actions}>
|
|
132
|
+
{!claim.verified && (
|
|
133
|
+
<>
|
|
134
|
+
<button
|
|
135
|
+
type="button"
|
|
136
|
+
className={cls.primaryButton}
|
|
137
|
+
disabled={busy !== null}
|
|
138
|
+
onClick={() => run('dns', () => onVerifyDns?.(claim))}
|
|
139
|
+
>
|
|
140
|
+
{busy === 'dns' ? 'Checking…' : 'Verify DNS'}
|
|
141
|
+
</button>
|
|
142
|
+
<button
|
|
143
|
+
type="button"
|
|
144
|
+
className={cls.secondaryButton}
|
|
145
|
+
disabled={busy !== null}
|
|
146
|
+
onClick={() =>
|
|
147
|
+
run('email-initiate', async () => {
|
|
148
|
+
await onInitiateEmail?.(claim)
|
|
149
|
+
setEmailInitiated(true)
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
>
|
|
153
|
+
{busy === 'email-initiate' ? 'Sending…' : 'Email attestation'}
|
|
154
|
+
</button>
|
|
155
|
+
</>
|
|
156
|
+
)}
|
|
157
|
+
{onRevoke && (
|
|
158
|
+
<button
|
|
159
|
+
type="button"
|
|
160
|
+
className={cls.revokeButton}
|
|
161
|
+
disabled={busy !== null}
|
|
162
|
+
onClick={() => run('revoke', () => onRevoke(claim))}
|
|
163
|
+
>
|
|
164
|
+
{busy === 'revoke' ? 'Revoking…' : 'Revoke'}
|
|
165
|
+
</button>
|
|
166
|
+
)}
|
|
167
|
+
</div>
|
|
168
|
+
</article>
|
|
169
|
+
)
|
|
170
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* InviteMemberDialog — collects email + role, calls the supplied submit
|
|
5
|
+
* handler (defaults to a no-op so the caller MUST wire it; in practice the
|
|
6
|
+
* caller passes api.teamInvitations.create or useInvitations().bulkInvite).
|
|
7
|
+
*
|
|
8
|
+
* Self-contained modal — uses a fixed overlay, doesn't pull Radix Dialog,
|
|
9
|
+
* so the test harness can render the open state straightforwardly. startsim-o7s.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as React from 'react'
|
|
13
|
+
import { RoleSelector } from './RoleSelector'
|
|
14
|
+
import type { InvitationLite, TeamRole } from './types'
|
|
15
|
+
import {
|
|
16
|
+
INVITE_DIALOG_DEFAULTS,
|
|
17
|
+
type InviteMemberDialogClassNames,
|
|
18
|
+
} from './invite-member-dialog-default-class-names'
|
|
19
|
+
|
|
20
|
+
export interface InviteMemberDialogProps {
|
|
21
|
+
/** Team the invitation will be scoped to. */
|
|
22
|
+
teamId: string
|
|
23
|
+
/**
|
|
24
|
+
* Submit hook. When undefined the dialog won't send anything — useful for
|
|
25
|
+
* Storybook-style harnesses. Apps typically pass
|
|
26
|
+
* (input) => api.teamInvitations.create(input)
|
|
27
|
+
*/
|
|
28
|
+
onSubmit?: (input: { email: string; teamId: string; role: TeamRole }) => Promise<InvitationLite | void>
|
|
29
|
+
/** Notification when an invitation has been created. */
|
|
30
|
+
onInvited?: (invitation: InvitationLite | void) => void
|
|
31
|
+
/** Trigger button label. */
|
|
32
|
+
triggerLabel?: string
|
|
33
|
+
/** Title in the modal header. */
|
|
34
|
+
title?: string
|
|
35
|
+
/** Default role pre-selected. */
|
|
36
|
+
defaultRole?: TeamRole
|
|
37
|
+
/** Available roles in the dropdown. Defaults to admin/member/viewer (no owner). */
|
|
38
|
+
availableRoles?: TeamRole[]
|
|
39
|
+
classNames?: InviteMemberDialogClassNames
|
|
40
|
+
/** External open-state control. When omitted the trigger button manages it. */
|
|
41
|
+
open?: boolean
|
|
42
|
+
onOpenChange?: (open: boolean) => void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function InviteMemberDialog({
|
|
46
|
+
teamId,
|
|
47
|
+
onSubmit,
|
|
48
|
+
onInvited,
|
|
49
|
+
triggerLabel = 'Invite member',
|
|
50
|
+
title = 'Invite a teammate',
|
|
51
|
+
defaultRole = 'member',
|
|
52
|
+
availableRoles = ['admin', 'member', 'viewer'],
|
|
53
|
+
classNames,
|
|
54
|
+
open: controlledOpen,
|
|
55
|
+
onOpenChange,
|
|
56
|
+
}: InviteMemberDialogProps) {
|
|
57
|
+
const cls = { ...INVITE_DIALOG_DEFAULTS, ...(classNames ?? {}) }
|
|
58
|
+
const [internalOpen, setInternalOpen] = React.useState(false)
|
|
59
|
+
const open = controlledOpen ?? internalOpen
|
|
60
|
+
const setOpen = (next: boolean) => {
|
|
61
|
+
if (controlledOpen === undefined) setInternalOpen(next)
|
|
62
|
+
onOpenChange?.(next)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const [email, setEmail] = React.useState('')
|
|
66
|
+
const [role, setRole] = React.useState<TeamRole>(defaultRole)
|
|
67
|
+
const [error, setError] = React.useState('')
|
|
68
|
+
const [success, setSuccess] = React.useState('')
|
|
69
|
+
const [submitting, setSubmitting] = React.useState(false)
|
|
70
|
+
|
|
71
|
+
function reset() {
|
|
72
|
+
setEmail('')
|
|
73
|
+
setRole(defaultRole)
|
|
74
|
+
setError('')
|
|
75
|
+
setSuccess('')
|
|
76
|
+
setSubmitting(false)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function close() {
|
|
80
|
+
setOpen(false)
|
|
81
|
+
reset()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
85
|
+
e.preventDefault()
|
|
86
|
+
setError('')
|
|
87
|
+
setSuccess('')
|
|
88
|
+
if (!email.trim()) {
|
|
89
|
+
setError('Email is required')
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
setSubmitting(true)
|
|
93
|
+
try {
|
|
94
|
+
let invited: InvitationLite | void = undefined
|
|
95
|
+
if (onSubmit) {
|
|
96
|
+
invited = await onSubmit({ email: email.trim(), teamId, role })
|
|
97
|
+
}
|
|
98
|
+
setSuccess(`Invitation sent to ${email.trim()}`)
|
|
99
|
+
setEmail('')
|
|
100
|
+
onInvited?.(invited)
|
|
101
|
+
} catch (err) {
|
|
102
|
+
setError(err instanceof Error ? err.message : 'Could not send invitation')
|
|
103
|
+
} finally {
|
|
104
|
+
setSubmitting(false)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<>
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
className={cls.trigger}
|
|
113
|
+
onClick={() => setOpen(true)}
|
|
114
|
+
aria-haspopup="dialog"
|
|
115
|
+
aria-expanded={open}
|
|
116
|
+
>
|
|
117
|
+
{triggerLabel}
|
|
118
|
+
</button>
|
|
119
|
+
{open && (
|
|
120
|
+
<div className={cls.overlay} role="presentation" onClick={close}>
|
|
121
|
+
<div
|
|
122
|
+
className={cls.panel}
|
|
123
|
+
role="dialog"
|
|
124
|
+
aria-modal="true"
|
|
125
|
+
aria-label={title}
|
|
126
|
+
onClick={(e) => e.stopPropagation()}
|
|
127
|
+
>
|
|
128
|
+
<div className={cls.header}>
|
|
129
|
+
<h2 className={cls.title}>{title}</h2>
|
|
130
|
+
</div>
|
|
131
|
+
<form onSubmit={handleSubmit}>
|
|
132
|
+
<div className={cls.body}>
|
|
133
|
+
<div className={cls.fieldRow}>
|
|
134
|
+
<label htmlFor="invite-email" className={cls.label}>Email</label>
|
|
135
|
+
<input
|
|
136
|
+
id="invite-email"
|
|
137
|
+
type="email"
|
|
138
|
+
value={email}
|
|
139
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
140
|
+
required
|
|
141
|
+
autoComplete="email"
|
|
142
|
+
className={cls.input}
|
|
143
|
+
disabled={submitting}
|
|
144
|
+
/>
|
|
145
|
+
</div>
|
|
146
|
+
<div className={cls.fieldRow}>
|
|
147
|
+
<label htmlFor="invite-role" className={cls.label}>Role</label>
|
|
148
|
+
<RoleSelector
|
|
149
|
+
value={role}
|
|
150
|
+
onChange={setRole}
|
|
151
|
+
availableRoles={availableRoles}
|
|
152
|
+
ariaLabel="Invite role"
|
|
153
|
+
disabled={submitting}
|
|
154
|
+
/>
|
|
155
|
+
</div>
|
|
156
|
+
{error && <p className={cls.errorText} role="alert">{error}</p>}
|
|
157
|
+
{success && <p className={cls.successText}>{success}</p>}
|
|
158
|
+
</div>
|
|
159
|
+
<div className={cls.footer}>
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
className={cls.cancelButton}
|
|
163
|
+
onClick={close}
|
|
164
|
+
disabled={submitting}
|
|
165
|
+
>
|
|
166
|
+
Close
|
|
167
|
+
</button>
|
|
168
|
+
<button
|
|
169
|
+
type="submit"
|
|
170
|
+
className={cls.submitButton}
|
|
171
|
+
disabled={submitting}
|
|
172
|
+
>
|
|
173
|
+
{submitting ? 'Sending…' : 'Send invitation'}
|
|
174
|
+
</button>
|
|
175
|
+
</div>
|
|
176
|
+
</form>
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
)}
|
|
180
|
+
</>
|
|
181
|
+
)
|
|
182
|
+
}
|