@kelpie/ui 0.9.0 → 0.11.0
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/dist/api/resources/activities.d.ts +2 -1
- package/dist/api/resources/activities.d.ts.map +1 -1
- package/dist/api/resources/activities.js +6 -2
- package/dist/api/resources/activities.js.map +1 -1
- package/dist/api/resources/conversions.d.ts +16 -0
- package/dist/api/resources/conversions.d.ts.map +1 -0
- package/dist/api/resources/conversions.js +81 -0
- package/dist/api/resources/conversions.js.map +1 -0
- package/dist/api/resources/enquiries.d.ts +2 -7
- package/dist/api/resources/enquiries.d.ts.map +1 -1
- package/dist/api/resources/enquiries.js +2 -30
- package/dist/api/resources/enquiries.js.map +1 -1
- package/dist/api/resources/importJobs.d.ts +2 -2
- package/dist/api/resources/importJobs.d.ts.map +1 -1
- package/dist/api/resources/importJobs.js.map +1 -1
- package/dist/app/KelpieApp.js +1 -1
- package/dist/app/KelpieApp.js.map +1 -1
- package/dist/components/ActivitiesPanel.d.ts +2 -2
- package/dist/components/ActivitiesPanel.d.ts.map +1 -1
- package/dist/components/ActivitiesPanel.js +7 -5
- package/dist/components/ActivitiesPanel.js.map +1 -1
- package/dist/components/ApiKeysPanel.d.ts +0 -7
- package/dist/components/ApiKeysPanel.d.ts.map +1 -1
- package/dist/components/ApiKeysPanel.js +83 -16
- package/dist/components/ApiKeysPanel.js.map +1 -1
- package/dist/components/ConvertRecordDialog.d.ts +30 -0
- package/dist/components/ConvertRecordDialog.d.ts.map +1 -0
- package/dist/components/ConvertRecordDialog.js +50 -0
- package/dist/components/ConvertRecordDialog.js.map +1 -0
- package/dist/components/Shell.d.ts.map +1 -1
- package/dist/components/Shell.js +19 -7
- package/dist/components/Shell.js.map +1 -1
- package/dist/pages/DealDetail.d.ts.map +1 -1
- package/dist/pages/DealDetail.js +15 -2
- package/dist/pages/DealDetail.js.map +1 -1
- package/dist/pages/DecisionsPage.d.ts.map +1 -1
- package/dist/pages/DecisionsPage.js +1 -7
- package/dist/pages/DecisionsPage.js.map +1 -1
- package/dist/pages/EnquiryDetail.d.ts.map +1 -1
- package/dist/pages/EnquiryDetail.js +17 -25
- package/dist/pages/EnquiryDetail.js.map +1 -1
- package/dist/pages/ListDetail.d.ts.map +1 -1
- package/dist/pages/ListDetail.js +1 -14
- package/dist/pages/ListDetail.js.map +1 -1
- package/dist/pages/OpportunityDetail.d.ts.map +1 -1
- package/dist/pages/OpportunityDetail.js +15 -2
- package/dist/pages/OpportunityDetail.js.map +1 -1
- package/dist/pages/PartnershipDetail.d.ts.map +1 -1
- package/dist/pages/PartnershipDetail.js +15 -2
- package/dist/pages/PartnershipDetail.js.map +1 -1
- package/dist/pages/RaiseDetail.d.ts.map +1 -1
- package/dist/pages/RaiseDetail.js +15 -2
- package/dist/pages/RaiseDetail.js.map +1 -1
- package/dist/pages/admin/DataPage.d.ts.map +1 -1
- package/dist/pages/admin/DataPage.js +3 -3
- package/dist/pages/admin/DataPage.js.map +1 -1
- package/dist/pages/admin/PrivacyPage.js +3 -3
- package/dist/pages/admin/PrivacyPage.js.map +1 -1
- package/dist/pages/admin/TeamPage.js +1 -1
- package/dist/pages/admin/TeamPage.js.map +1 -1
- package/dist/pages/admin/WorkspaceSettingsPage.js +2 -12
- package/dist/pages/admin/WorkspaceSettingsPage.js.map +1 -1
- package/dist/pages/forms/AddFieldMenu.js +2 -2
- package/dist/pages/forms/AddFieldMenu.js.map +1 -1
- package/dist/pages/forms/FieldsEditor.d.ts.map +1 -1
- package/dist/pages/forms/FieldsEditor.js +22 -10
- package/dist/pages/forms/FieldsEditor.js.map +1 -1
- package/dist/pages/forms/MapTargetSearch.d.ts +7 -0
- package/dist/pages/forms/MapTargetSearch.d.ts.map +1 -0
- package/dist/pages/forms/MapTargetSearch.js +32 -0
- package/dist/pages/forms/MapTargetSearch.js.map +1 -0
- package/dist/pages/forms/fieldList.d.ts +6 -7
- package/dist/pages/forms/fieldList.d.ts.map +1 -1
- package/dist/pages/forms/fieldList.js +14 -16
- package/dist/pages/forms/fieldList.js.map +1 -1
- package/dist/pages/forms/template.d.ts.map +1 -1
- package/dist/pages/forms/template.js +7 -0
- package/dist/pages/forms/template.js.map +1 -1
- package/package.json +2 -2
- package/src/api/resources/activities.ts +10 -2
- package/src/api/resources/conversions.ts +125 -0
- package/src/api/resources/enquiries.ts +9 -35
- package/src/api/resources/importJobs.ts +3 -2
- package/src/app/KelpieApp.tsx +1 -0
- package/src/components/ActivitiesPanel.tsx +20 -14
- package/src/components/ApiKeysPanel.tsx +206 -21
- package/src/components/ConvertRecordDialog.tsx +210 -0
- package/src/components/Shell.tsx +91 -36
- package/src/pages/DealDetail.tsx +35 -0
- package/src/pages/DecisionsPage.tsx +0 -14
- package/src/pages/EnquiryDetail.tsx +37 -90
- package/src/pages/ListDetail.tsx +0 -47
- package/src/pages/OpportunityDetail.tsx +35 -0
- package/src/pages/PartnershipDetail.tsx +35 -0
- package/src/pages/RaiseDetail.tsx +35 -0
- package/src/pages/admin/DataPage.tsx +14 -9
- package/src/pages/admin/PrivacyPage.tsx +4 -4
- package/src/pages/admin/TeamPage.tsx +1 -2
- package/src/pages/admin/WorkspaceSettingsPage.tsx +1 -31
- package/src/pages/forms/AddFieldMenu.tsx +2 -2
- package/src/pages/forms/FieldsEditor.tsx +45 -20
- package/src/pages/forms/MapTargetSearch.tsx +69 -0
- package/src/pages/forms/fieldList.ts +34 -16
- package/src/pages/forms/template.ts +7 -0
|
@@ -1,11 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type {
|
|
3
|
-
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
|
4
|
-
|
|
5
|
-
import type { QueryParameters } from '../client.ts'
|
|
6
|
-
import { useApiClient } from '../context.ts'
|
|
7
|
-
import { toError } from '../errors.ts'
|
|
1
|
+
import { enquiryBody, enquirySchema } from '@kelpie/schemas'
|
|
2
|
+
import type { Enquiry, EnquiryInput } from '@kelpie/schemas'
|
|
8
3
|
import { createResourceHooks } from '../resource.ts'
|
|
4
|
+
import type { QueryParameters } from '../client.ts'
|
|
9
5
|
import type {
|
|
10
6
|
ListOptions,
|
|
11
7
|
MutationResult,
|
|
@@ -88,31 +84,9 @@ export function useDeleteEnquiry(): MutationResult<string, void> {
|
|
|
88
84
|
return enquiries.useRemove()
|
|
89
85
|
}
|
|
90
86
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const client = useApiClient()
|
|
98
|
-
const queryClient = useQueryClient()
|
|
99
|
-
const mutation = useMutation({
|
|
100
|
-
mutationFn: (id: string) =>
|
|
101
|
-
client.post(`/enquiries/${id}/convert`, {}, dealSchema.parse),
|
|
102
|
-
onSuccess: () => {
|
|
103
|
-
// The enquiry, deals list, activities on both sides are all stale.
|
|
104
|
-
void queryClient.invalidateQueries({ queryKey: ['enquiries'] })
|
|
105
|
-
void queryClient.invalidateQueries({ queryKey: ['deals'] })
|
|
106
|
-
void queryClient.invalidateQueries({ queryKey: ['activities'] })
|
|
107
|
-
},
|
|
108
|
-
})
|
|
109
|
-
|
|
110
|
-
return {
|
|
111
|
-
run: (input) => {
|
|
112
|
-
mutation.mutate(input)
|
|
113
|
-
},
|
|
114
|
-
runAsync: (input) => mutation.mutateAsync(input),
|
|
115
|
-
isPending: mutation.isPending,
|
|
116
|
-
error: toError(mutation.error),
|
|
117
|
-
}
|
|
118
|
-
}
|
|
87
|
+
export {
|
|
88
|
+
useConvertEnquiry,
|
|
89
|
+
useConvertPipelineRecord,
|
|
90
|
+
detailPathForPipelineKind,
|
|
91
|
+
convertedTargetPath,
|
|
92
|
+
} from './conversions.ts'
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { importJobSchema, isImportJobSettled } from '@kelpie/schemas'
|
|
2
2
|
import type {
|
|
3
|
+
ExportObject,
|
|
3
4
|
ImportColumnMap,
|
|
4
5
|
ImportConflictMode,
|
|
5
6
|
ImportJob,
|
|
@@ -205,7 +206,7 @@ export interface CsvDownload {
|
|
|
205
206
|
* a failure is an `ApiError` the page can show beside the button.
|
|
206
207
|
*/
|
|
207
208
|
export function useExportCsv(): MutationResult<
|
|
208
|
-
{ object:
|
|
209
|
+
{ object: ExportObject; template: boolean },
|
|
209
210
|
CsvDownload
|
|
210
211
|
> {
|
|
211
212
|
const client = useApiClient()
|
|
@@ -216,7 +217,7 @@ export function useExportCsv(): MutationResult<
|
|
|
216
217
|
object,
|
|
217
218
|
template,
|
|
218
219
|
}: {
|
|
219
|
-
object:
|
|
220
|
+
object: ExportObject
|
|
220
221
|
template: boolean
|
|
221
222
|
}): Promise<CsvDownload> => {
|
|
222
223
|
const path = template ? `/export/templates/${object}.csv` : `/export/${object}.csv`
|
package/src/app/KelpieApp.tsx
CHANGED
|
@@ -180,6 +180,7 @@ function AppRoutes(): React.JSX.Element {
|
|
|
180
180
|
{/* The optional segment is the mockup's: /handbook opens the first page. */}
|
|
181
181
|
<Route path="handbook" element={<HandbookLayout />} />
|
|
182
182
|
<Route path="handbook/:pageId" element={<HandbookLayout />} />
|
|
183
|
+
<Route path="admin" element={<Navigate to="/admin/workspace" replace />} />
|
|
183
184
|
<Route path="admin/workspace" element={<WorkspaceSettingsPage />} />
|
|
184
185
|
<Route path="admin/team" element={<TeamPage />} />
|
|
185
186
|
<Route path="admin/fields" element={<FieldsPage />} />
|
|
@@ -57,6 +57,9 @@ interface MonthGroup {
|
|
|
57
57
|
readonly items: readonly Activity[]
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/** How many events the Overview tab's latest-activity block shows. */
|
|
61
|
+
const LATEST_ACTIVITY_COUNT = 5
|
|
62
|
+
|
|
60
63
|
/** Consecutive runs, not a map: the list is already ordered, so a month never recurs. */
|
|
61
64
|
function groupByMonth(
|
|
62
65
|
activities: readonly Activity[],
|
|
@@ -126,19 +129,19 @@ export function ActivitiesPanel({
|
|
|
126
129
|
}
|
|
127
130
|
|
|
128
131
|
/**
|
|
129
|
-
* The
|
|
132
|
+
* The five most recent events, for a record's Overview tab.
|
|
130
133
|
*
|
|
131
134
|
* Its own request rather than a slice of the panel's: the panel lives on a
|
|
132
|
-
* different tab, so on Overview there is no list to take the first
|
|
135
|
+
* different tab, so on Overview there is no list to take the first rows of.
|
|
133
136
|
*/
|
|
134
137
|
export function LatestActivity({
|
|
135
138
|
targetType,
|
|
136
139
|
targetId,
|
|
137
140
|
}: ActivitiesPanelProps): React.JSX.Element {
|
|
138
|
-
const activities = useActivities({ targetType, targetId })
|
|
141
|
+
const activities = useActivities({ targetType, targetId }, { limit: LATEST_ACTIVITY_COUNT })
|
|
139
142
|
const members = useMembers()
|
|
140
143
|
const timezone = useTimezone()
|
|
141
|
-
const latest = activities.records
|
|
144
|
+
const latest = activities.records
|
|
142
145
|
|
|
143
146
|
return (
|
|
144
147
|
<section>
|
|
@@ -147,20 +150,23 @@ export function LatestActivity({
|
|
|
147
150
|
{activities.error !== null && <ErrorPanel error={activities.error} />}
|
|
148
151
|
{activities.isLoading && <p className="text-[13px] text-ink-faint">Loading activity…</p>}
|
|
149
152
|
|
|
150
|
-
{!activities.isLoading && latest ===
|
|
153
|
+
{!activities.isLoading && latest.length === 0 && activities.error === null && (
|
|
151
154
|
<p className="text-[13px] text-ink-faint">No activity yet.</p>
|
|
152
155
|
)}
|
|
153
156
|
|
|
154
|
-
{latest
|
|
157
|
+
{latest.length > 0 && (
|
|
155
158
|
<ol className="overflow-hidden rounded-md border border-border">
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
159
|
+
{latest.map((activity, index) => (
|
|
160
|
+
<ActivityEvent
|
|
161
|
+
key={activity.id}
|
|
162
|
+
activity={activity}
|
|
163
|
+
actorName={actorNameFor(activity, members.nameById)}
|
|
164
|
+
contextType={targetType}
|
|
165
|
+
contextId={targetId}
|
|
166
|
+
isLast={index === latest.length - 1}
|
|
167
|
+
timezone={timezone}
|
|
168
|
+
/>
|
|
169
|
+
))}
|
|
164
170
|
</ol>
|
|
165
171
|
)}
|
|
166
172
|
</section>
|
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
import type { ApiKey, ApiKeyKind, CreatedApiKey } from '@kelpie/schemas'
|
|
2
|
-
import {
|
|
1
|
+
import type { ApiKey, ApiKeyGranularScope, ApiKeyKind, ApiKeyScope, CreatedApiKey } from '@kelpie/schemas'
|
|
2
|
+
import {
|
|
3
|
+
API_KEY_PRESET_SCOPES,
|
|
4
|
+
API_KEY_SCOPE_GROUPS,
|
|
5
|
+
API_KEY_SCOPE_LABELS,
|
|
6
|
+
expandApiKeyScopes,
|
|
7
|
+
isApiKeyGranularScope,
|
|
8
|
+
isApiKeyPresetScope,
|
|
9
|
+
} from '@kelpie/schemas'
|
|
10
|
+
import { useMemo, useState } from 'react'
|
|
3
11
|
import type { FormEvent } from 'react'
|
|
4
12
|
|
|
5
13
|
import { useTimezone } from '../api/resources/account.ts'
|
|
@@ -11,22 +19,17 @@ import { ErrorPanel, LoadingPanel } from './QueryState.tsx'
|
|
|
11
19
|
/**
|
|
12
20
|
* The create/list/revoke flow shared by workspace keys (`/admin/api-keys`) and
|
|
13
21
|
* personal keys (`/account/api-keys`).
|
|
14
|
-
*
|
|
15
|
-
* `kind` and the three strings below are the only difference between the two
|
|
16
|
-
* pages, the same way `KanbanBoard` takes a `kind` for Deals versus
|
|
17
|
-
* Opportunities. Who may reach this component at all — the admin gate on the
|
|
18
|
-
* workspace page, none on the personal page — is the caller's decision; the API
|
|
19
|
-
* enforces its own half regardless.
|
|
20
22
|
*/
|
|
21
23
|
|
|
22
24
|
export interface ApiKeysPanelProps {
|
|
23
25
|
readonly kind: ApiKeyKind
|
|
24
|
-
/** Shown in the create form's name field, e.g. "CI pipeline" or "Laptop Claude". */
|
|
25
26
|
readonly namePlaceholder: string
|
|
26
27
|
readonly createTitle: string
|
|
27
28
|
readonly emptyMessage: string
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
const PRESET_OPTIONS = API_KEY_PRESET_SCOPES.filter((scope) => scope !== 'write:all')
|
|
32
|
+
|
|
30
33
|
export function ApiKeysPanel({
|
|
31
34
|
kind,
|
|
32
35
|
namePlaceholder,
|
|
@@ -96,6 +99,8 @@ export function ApiKeysPanel({
|
|
|
96
99
|
)
|
|
97
100
|
}
|
|
98
101
|
|
|
102
|
+
type AccessMode = 'full' | 'presets' | 'custom'
|
|
103
|
+
|
|
99
104
|
function CreateKeyModal({
|
|
100
105
|
kind,
|
|
101
106
|
title,
|
|
@@ -111,18 +116,78 @@ function CreateKeyModal({
|
|
|
111
116
|
}): React.JSX.Element {
|
|
112
117
|
const createApiKey = useCreateApiKey()
|
|
113
118
|
const [name, setName] = useState('')
|
|
119
|
+
const [accessMode, setAccessMode] = useState<AccessMode>('full')
|
|
120
|
+
const [selectedPresets, setSelectedPresets] = useState<readonly ApiKeyScope[]>([])
|
|
121
|
+
const [selectedCustom, setSelectedCustom] = useState<readonly ApiKeyScope[]>([])
|
|
122
|
+
const [showCustom, setShowCustom] = useState(false)
|
|
123
|
+
|
|
124
|
+
const presetExpandedScopes = useMemo(
|
|
125
|
+
() => expandApiKeyScopes(selectedPresets),
|
|
126
|
+
[selectedPresets],
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
const showScopeGrid =
|
|
130
|
+
(accessMode === 'custom' && showCustom) ||
|
|
131
|
+
(accessMode === 'presets' && selectedPresets.length > 0)
|
|
132
|
+
|
|
133
|
+
function scopesForCreate(): readonly ApiKeyScope[] | undefined {
|
|
134
|
+
if (accessMode === 'full') {
|
|
135
|
+
return undefined
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (accessMode === 'presets') {
|
|
139
|
+
return selectedPresets.length === 0 ? undefined : selectedPresets
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return selectedCustom.length === 0 ? undefined : selectedCustom
|
|
143
|
+
}
|
|
114
144
|
|
|
115
145
|
function submit(event: FormEvent): void {
|
|
116
146
|
event.preventDefault()
|
|
147
|
+
const scopes = scopesForCreate()
|
|
148
|
+
|
|
117
149
|
createApiKey
|
|
118
|
-
.runAsync({
|
|
150
|
+
.runAsync({
|
|
151
|
+
name: name.trim(),
|
|
152
|
+
kind,
|
|
153
|
+
...(scopes === undefined ? {} : { scopes }),
|
|
154
|
+
})
|
|
119
155
|
.then(onCreated)
|
|
120
156
|
.catch(() => undefined)
|
|
121
157
|
}
|
|
122
158
|
|
|
159
|
+
function togglePreset(scope: ApiKeyScope): void {
|
|
160
|
+
setAccessMode('presets')
|
|
161
|
+
setSelectedCustom([])
|
|
162
|
+
setShowCustom(false)
|
|
163
|
+
setSelectedPresets((current) =>
|
|
164
|
+
current.includes(scope) ? current.filter((entry) => entry !== scope) : [...current, scope],
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function toggleCustom(scope: ApiKeyScope): void {
|
|
169
|
+
setAccessMode('custom')
|
|
170
|
+
setSelectedPresets([])
|
|
171
|
+
setSelectedCustom((current) =>
|
|
172
|
+
current.includes(scope) ? current.filter((entry) => entry !== scope) : [...current, scope],
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function isScopeChecked(scope: ApiKeyGranularScope): boolean {
|
|
177
|
+
if (accessMode === 'custom') {
|
|
178
|
+
return selectedCustom.includes(scope)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (accessMode === 'presets') {
|
|
182
|
+
return presetExpandedScopes.has(scope)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return false
|
|
186
|
+
}
|
|
187
|
+
|
|
123
188
|
return (
|
|
124
189
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4">
|
|
125
|
-
<div className="animate-slide-in w-full max-w-
|
|
190
|
+
<div className="animate-slide-in max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-md border border-border bg-surface-raised p-5">
|
|
126
191
|
<form onSubmit={submit}>
|
|
127
192
|
<h2 className="text-[15px] font-semibold text-ink">{title}</h2>
|
|
128
193
|
<label className="mt-4 block">
|
|
@@ -139,6 +204,113 @@ function CreateKeyModal({
|
|
|
139
204
|
/>
|
|
140
205
|
</label>
|
|
141
206
|
|
|
207
|
+
<fieldset className="mt-4">
|
|
208
|
+
<legend className="mb-2 text-[12px] font-medium text-ink">Access</legend>
|
|
209
|
+
<div className="space-y-2">
|
|
210
|
+
<label className="flex items-center gap-2 text-[13px] text-ink">
|
|
211
|
+
<input
|
|
212
|
+
type="radio"
|
|
213
|
+
name="access"
|
|
214
|
+
checked={accessMode === 'full'}
|
|
215
|
+
onChange={() => {
|
|
216
|
+
setAccessMode('full')
|
|
217
|
+
setSelectedPresets([])
|
|
218
|
+
setSelectedCustom([])
|
|
219
|
+
setShowCustom(false)
|
|
220
|
+
}}
|
|
221
|
+
/>
|
|
222
|
+
Full access
|
|
223
|
+
</label>
|
|
224
|
+
<div className="rounded-md border border-border p-3">
|
|
225
|
+
<p className="mb-2 text-[11px] font-medium tracking-wide text-ink-muted uppercase">
|
|
226
|
+
Presets
|
|
227
|
+
</p>
|
|
228
|
+
<div className="space-y-1.5">
|
|
229
|
+
{PRESET_OPTIONS.map((scope) => (
|
|
230
|
+
<label key={scope} className="flex items-center gap-2 text-[13px] text-ink">
|
|
231
|
+
<input
|
|
232
|
+
type="checkbox"
|
|
233
|
+
checked={accessMode === 'presets' && selectedPresets.includes(scope)}
|
|
234
|
+
onChange={() => {
|
|
235
|
+
togglePreset(scope)
|
|
236
|
+
}}
|
|
237
|
+
/>
|
|
238
|
+
{API_KEY_SCOPE_LABELS[scope]}
|
|
239
|
+
</label>
|
|
240
|
+
))}
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
{accessMode !== 'presets' && (
|
|
244
|
+
<button
|
|
245
|
+
type="button"
|
|
246
|
+
onClick={() => {
|
|
247
|
+
setShowCustom((open) => !open)
|
|
248
|
+
if (!showCustom) {
|
|
249
|
+
setAccessMode('custom')
|
|
250
|
+
setSelectedPresets([])
|
|
251
|
+
}
|
|
252
|
+
}}
|
|
253
|
+
className="text-[12px] font-medium text-accent hover:underline"
|
|
254
|
+
>
|
|
255
|
+
{showCustom ? 'Hide custom scopes' : 'Custom scopes…'}
|
|
256
|
+
</button>
|
|
257
|
+
)}
|
|
258
|
+
{showScopeGrid && (
|
|
259
|
+
<div className="space-y-3 rounded-md border border-border p-3">
|
|
260
|
+
{accessMode === 'presets' && (
|
|
261
|
+
<p className="text-[11px] text-ink-muted">
|
|
262
|
+
Included in the selected preset{selectedPresets.length === 1 ? '' : 's'}.
|
|
263
|
+
</p>
|
|
264
|
+
)}
|
|
265
|
+
{API_KEY_SCOPE_GROUPS.map((group) => (
|
|
266
|
+
<div key={group.label}>
|
|
267
|
+
<p className="mb-1.5 text-[11px] font-medium tracking-wide text-ink-muted uppercase">
|
|
268
|
+
{group.label}
|
|
269
|
+
</p>
|
|
270
|
+
<div className="grid grid-cols-1 gap-1 sm:grid-cols-2">
|
|
271
|
+
{group.scopes.map((scope) => (
|
|
272
|
+
<label
|
|
273
|
+
key={scope}
|
|
274
|
+
className={`flex items-center gap-2 text-[12px] ${
|
|
275
|
+
accessMode === 'presets' ? 'text-ink-muted' : 'text-ink'
|
|
276
|
+
}`}
|
|
277
|
+
>
|
|
278
|
+
<input
|
|
279
|
+
type="checkbox"
|
|
280
|
+
checked={isScopeChecked(scope)}
|
|
281
|
+
disabled={accessMode === 'presets'}
|
|
282
|
+
onChange={() => {
|
|
283
|
+
toggleCustom(scope)
|
|
284
|
+
}}
|
|
285
|
+
/>
|
|
286
|
+
{API_KEY_SCOPE_LABELS[scope]}
|
|
287
|
+
</label>
|
|
288
|
+
))}
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
))}
|
|
292
|
+
{accessMode === 'presets' && (
|
|
293
|
+
<button
|
|
294
|
+
type="button"
|
|
295
|
+
onClick={() => {
|
|
296
|
+
setAccessMode('custom')
|
|
297
|
+
setSelectedPresets([])
|
|
298
|
+
setSelectedCustom([...presetExpandedScopes])
|
|
299
|
+
setShowCustom(true)
|
|
300
|
+
}}
|
|
301
|
+
className="text-[12px] font-medium text-accent hover:underline"
|
|
302
|
+
>
|
|
303
|
+
Switch to custom scopes…
|
|
304
|
+
</button>
|
|
305
|
+
)}
|
|
306
|
+
</div>
|
|
307
|
+
)}
|
|
308
|
+
</div>
|
|
309
|
+
<p className="mt-2 text-[11px] text-ink-faint">
|
|
310
|
+
Write includes read for the same resource. Presets cover many scopes at once.
|
|
311
|
+
</p>
|
|
312
|
+
</fieldset>
|
|
313
|
+
|
|
142
314
|
{createApiKey.error !== null && (
|
|
143
315
|
<div className="mt-3">
|
|
144
316
|
<ErrorPanel error={createApiKey.error} />
|
|
@@ -167,12 +339,6 @@ function CreateKeyModal({
|
|
|
167
339
|
)
|
|
168
340
|
}
|
|
169
341
|
|
|
170
|
-
/**
|
|
171
|
-
* The secret, shown once.
|
|
172
|
-
*
|
|
173
|
-
* Nothing can retrieve it afterwards, so this panel says so plainly instead of
|
|
174
|
-
* leaving a reader to discover it by coming back for the value later.
|
|
175
|
-
*/
|
|
176
342
|
function SecretOnce({
|
|
177
343
|
apiKey,
|
|
178
344
|
onDismiss,
|
|
@@ -202,6 +368,27 @@ function SecretOnce({
|
|
|
202
368
|
)
|
|
203
369
|
}
|
|
204
370
|
|
|
371
|
+
function formatScopeSummary(scopes: readonly ApiKeyScope[]): string {
|
|
372
|
+
if (scopes.length === 0) {
|
|
373
|
+
return 'Full access'
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const presets = scopes.filter(isApiKeyPresetScope)
|
|
377
|
+
const granular = scopes.filter(isApiKeyGranularScope)
|
|
378
|
+
|
|
379
|
+
if (granular.length === 0) {
|
|
380
|
+
return presets.map((scope) => API_KEY_SCOPE_LABELS[scope]).join(', ')
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (presets.length === 0) {
|
|
384
|
+
return `Custom (${String(scopes.length)} scopes)`
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return [...presets.map((scope) => API_KEY_SCOPE_LABELS[scope]), `Custom (${String(granular.length)})`].join(
|
|
388
|
+
', ',
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
|
|
205
392
|
function ApiKeyTable({
|
|
206
393
|
keys,
|
|
207
394
|
emptyMessage,
|
|
@@ -227,6 +414,7 @@ function ApiKeyTable({
|
|
|
227
414
|
<thead>
|
|
228
415
|
<tr className="border-b border-border bg-surface text-[11px] font-semibold tracking-wide text-ink-muted uppercase">
|
|
229
416
|
<th className="px-4 py-2.5">Name</th>
|
|
417
|
+
<th className="px-4 py-2.5">Scopes</th>
|
|
230
418
|
<th className="px-4 py-2.5">Prefix</th>
|
|
231
419
|
<th className="px-4 py-2.5">Created</th>
|
|
232
420
|
<th className="px-4 py-2.5">Last used</th>
|
|
@@ -243,10 +431,6 @@ function ApiKeyTable({
|
|
|
243
431
|
)
|
|
244
432
|
}
|
|
245
433
|
|
|
246
|
-
/**
|
|
247
|
-
* Revoking a key takes effect at once and cannot be undone, so this asks first,
|
|
248
|
-
* the same two-step confirm `WebhooksPage` uses for removing a registration.
|
|
249
|
-
*/
|
|
250
434
|
function ApiKeyRow({
|
|
251
435
|
apiKey,
|
|
252
436
|
onRevoke,
|
|
@@ -262,6 +446,7 @@ function ApiKeyRow({
|
|
|
262
446
|
return (
|
|
263
447
|
<tr className="border-b border-border last:border-0">
|
|
264
448
|
<td className="px-4 py-3 font-medium">{apiKey.name}</td>
|
|
449
|
+
<td className="px-4 py-3 text-ink-muted">{formatScopeSummary(apiKey.scopes)}</td>
|
|
265
450
|
<td className="px-4 py-3 font-mono text-[12px] text-ink-muted">{apiKey.displayPrefix}</td>
|
|
266
451
|
<td className="px-4 py-3 text-ink-muted">{formatDate(apiKey.createdAt, timezone)}</td>
|
|
267
452
|
<td className="px-4 py-3 text-ink-muted">
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PIPELINE_KINDS,
|
|
3
|
+
PIPELINE_KIND_LABELS,
|
|
4
|
+
} from '@kelpie/schemas'
|
|
5
|
+
import type { ConvertPipelineRecordInput, PipelineKind } from '@kelpie/schemas'
|
|
6
|
+
import { useState } from 'react'
|
|
7
|
+
import { Link } from 'react-router'
|
|
8
|
+
|
|
9
|
+
import { useCompanies } from '../api/resources/companies.ts'
|
|
10
|
+
import type { MutationResult } from '../api/resource.ts'
|
|
11
|
+
import {
|
|
12
|
+
detailPathForPipelineKind,
|
|
13
|
+
type ConvertPipelineRecordArguments,
|
|
14
|
+
type ConvertedPipelineRecord,
|
|
15
|
+
} from '../api/resources/conversions.ts'
|
|
16
|
+
|
|
17
|
+
const COMPANY_REQUIRED_TARGETS = new Set<PipelineKind>(['deal', 'raise', 'partnership'])
|
|
18
|
+
|
|
19
|
+
export interface ConvertRecordDialogProps {
|
|
20
|
+
readonly sourceKind: PipelineKind
|
|
21
|
+
readonly recordName: string
|
|
22
|
+
readonly companyId: string | null
|
|
23
|
+
readonly isPending: boolean
|
|
24
|
+
readonly onCancel: () => void
|
|
25
|
+
readonly onConfirm: (body: ConvertPipelineRecordInput) => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function ConvertRecordDialog({
|
|
29
|
+
sourceKind,
|
|
30
|
+
recordName,
|
|
31
|
+
companyId,
|
|
32
|
+
isPending,
|
|
33
|
+
onCancel,
|
|
34
|
+
onConfirm,
|
|
35
|
+
}: ConvertRecordDialogProps): React.JSX.Element {
|
|
36
|
+
const targetOptions = PIPELINE_KINDS.filter((kind) => kind !== sourceKind)
|
|
37
|
+
const [targetType, setTargetType] = useState<PipelineKind>(targetOptions[0] ?? 'deal')
|
|
38
|
+
const [kind, setKind] = useState('')
|
|
39
|
+
const [selectedCompanyId, setSelectedCompanyId] = useState(companyId ?? '')
|
|
40
|
+
const companies = useCompanies({ limit: 100 })
|
|
41
|
+
|
|
42
|
+
const needsCompany = COMPANY_REQUIRED_TARGETS.has(targetType) && companyId === null
|
|
43
|
+
const needsKind = targetType === 'opportunity' || targetType === 'partnership'
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className="rounded-md border border-border bg-surface-raised p-4">
|
|
47
|
+
<p className="text-[13px] text-ink">
|
|
48
|
+
Convert <span className="font-semibold">{recordName}</span> to another record type. This
|
|
49
|
+
creates a new record, moves notes / activity / plans / decisions to it, copies linked people,
|
|
50
|
+
and leaves this record in place with a link.
|
|
51
|
+
</p>
|
|
52
|
+
|
|
53
|
+
<div className="mt-4 space-y-3">
|
|
54
|
+
<label className="block text-[12px] font-medium text-ink-muted">
|
|
55
|
+
Convert to
|
|
56
|
+
<select
|
|
57
|
+
value={targetType}
|
|
58
|
+
onChange={(event) => {
|
|
59
|
+
setTargetType(event.target.value as PipelineKind)
|
|
60
|
+
}}
|
|
61
|
+
className="mt-1 block w-full rounded-md border border-border bg-surface px-2.5 py-1.5 text-[13px] text-ink"
|
|
62
|
+
>
|
|
63
|
+
{targetOptions.map((kind) => (
|
|
64
|
+
<option key={kind} value={kind}>
|
|
65
|
+
{PIPELINE_KIND_LABELS[kind]}
|
|
66
|
+
</option>
|
|
67
|
+
))}
|
|
68
|
+
</select>
|
|
69
|
+
</label>
|
|
70
|
+
|
|
71
|
+
{needsKind && (
|
|
72
|
+
<label className="block text-[12px] font-medium text-ink-muted">
|
|
73
|
+
Kind
|
|
74
|
+
<input
|
|
75
|
+
value={kind}
|
|
76
|
+
onChange={(event) => {
|
|
77
|
+
setKind(event.target.value)
|
|
78
|
+
}}
|
|
79
|
+
className="mt-1 block w-full rounded-md border border-border bg-surface px-2.5 py-1.5 text-[13px] text-ink"
|
|
80
|
+
placeholder={targetType === 'opportunity' ? 'Grant' : 'Integration'}
|
|
81
|
+
/>
|
|
82
|
+
</label>
|
|
83
|
+
)}
|
|
84
|
+
|
|
85
|
+
{needsCompany && (
|
|
86
|
+
<label className="block text-[12px] font-medium text-ink-muted">
|
|
87
|
+
Company
|
|
88
|
+
<select
|
|
89
|
+
value={selectedCompanyId}
|
|
90
|
+
onChange={(event) => {
|
|
91
|
+
setSelectedCompanyId(event.target.value)
|
|
92
|
+
}}
|
|
93
|
+
className="mt-1 block w-full rounded-md border border-border bg-surface px-2.5 py-1.5 text-[13px] text-ink"
|
|
94
|
+
>
|
|
95
|
+
<option value="">Choose a company…</option>
|
|
96
|
+
{companies.records.map((company) => (
|
|
97
|
+
<option key={company.id} value={company.id}>
|
|
98
|
+
{company.name}
|
|
99
|
+
</option>
|
|
100
|
+
))}
|
|
101
|
+
</select>
|
|
102
|
+
</label>
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<div className="mt-4 flex justify-end gap-2">
|
|
107
|
+
<button
|
|
108
|
+
type="button"
|
|
109
|
+
onClick={onCancel}
|
|
110
|
+
disabled={isPending}
|
|
111
|
+
className="rounded-md px-2.5 py-1 text-[12px] font-medium text-ink-muted hover:text-ink disabled:opacity-50"
|
|
112
|
+
>
|
|
113
|
+
Cancel
|
|
114
|
+
</button>
|
|
115
|
+
<button
|
|
116
|
+
type="button"
|
|
117
|
+
disabled={isPending || (needsCompany && selectedCompanyId.length === 0)}
|
|
118
|
+
onClick={() => {
|
|
119
|
+
onConfirm({
|
|
120
|
+
targetType,
|
|
121
|
+
...(needsKind && kind.length > 0 ? { kind } : {}),
|
|
122
|
+
...(needsCompany ? { companyId: selectedCompanyId } : {}),
|
|
123
|
+
})
|
|
124
|
+
}}
|
|
125
|
+
className="rounded-md bg-accent px-3 py-1.5 text-[12px] font-semibold text-accent-fg hover:bg-accent-hover disabled:opacity-50"
|
|
126
|
+
>
|
|
127
|
+
{isPending ? 'Converting…' : 'Convert'}
|
|
128
|
+
</button>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface ConvertRecordActionProps {
|
|
135
|
+
readonly sourceKind: PipelineKind
|
|
136
|
+
readonly recordId: string
|
|
137
|
+
readonly recordName: string
|
|
138
|
+
readonly companyId: string | null
|
|
139
|
+
readonly convertedTo: { readonly targetType: PipelineKind; readonly targetId: string } | null
|
|
140
|
+
readonly convert: MutationResult<ConvertPipelineRecordArguments, ConvertedPipelineRecord>
|
|
141
|
+
readonly onConverted: (record: ConvertedPipelineRecord, targetType: PipelineKind) => void
|
|
142
|
+
readonly showDialog: boolean
|
|
143
|
+
readonly onOpenDialog: () => void
|
|
144
|
+
readonly onCloseDialog: () => void
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function ConvertRecordButton({
|
|
148
|
+
convertedTo,
|
|
149
|
+
convert,
|
|
150
|
+
onOpenDialog,
|
|
151
|
+
}: Pick<
|
|
152
|
+
ConvertRecordActionProps,
|
|
153
|
+
'convertedTo' | 'convert' | 'onOpenDialog'
|
|
154
|
+
>): React.JSX.Element {
|
|
155
|
+
if (convertedTo !== null) {
|
|
156
|
+
return (
|
|
157
|
+
<Link
|
|
158
|
+
to={detailPathForPipelineKind(convertedTo.targetType, convertedTo.targetId)}
|
|
159
|
+
className="rounded-md border border-border px-2.5 py-1 text-[12px] font-medium text-accent transition hover:border-accent hover:underline"
|
|
160
|
+
>
|
|
161
|
+
Converted — view {PIPELINE_KIND_LABELS[convertedTo.targetType].toLowerCase()}
|
|
162
|
+
</Link>
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<button
|
|
168
|
+
type="button"
|
|
169
|
+
onClick={onOpenDialog}
|
|
170
|
+
disabled={convert.isPending}
|
|
171
|
+
className="rounded-md border border-border px-2.5 py-1 text-[12px] font-medium text-ink-muted transition hover:border-accent hover:text-accent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-border disabled:hover:text-ink-muted"
|
|
172
|
+
>
|
|
173
|
+
Convert…
|
|
174
|
+
</button>
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function ConvertRecordAction({
|
|
179
|
+
sourceKind,
|
|
180
|
+
recordId,
|
|
181
|
+
recordName,
|
|
182
|
+
companyId,
|
|
183
|
+
convert,
|
|
184
|
+
onConverted,
|
|
185
|
+
showDialog,
|
|
186
|
+
onCloseDialog,
|
|
187
|
+
}: ConvertRecordActionProps): React.JSX.Element | null {
|
|
188
|
+
if (!showDialog) {
|
|
189
|
+
return null
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
<ConvertRecordDialog
|
|
194
|
+
sourceKind={sourceKind}
|
|
195
|
+
recordName={recordName}
|
|
196
|
+
companyId={companyId}
|
|
197
|
+
isPending={convert.isPending}
|
|
198
|
+
onCancel={onCloseDialog}
|
|
199
|
+
onConfirm={(body) => {
|
|
200
|
+
convert
|
|
201
|
+
.runAsync({ id: recordId, body })
|
|
202
|
+
.then((record) => {
|
|
203
|
+
onCloseDialog()
|
|
204
|
+
onConverted(record, body.targetType)
|
|
205
|
+
})
|
|
206
|
+
.catch(() => undefined)
|
|
207
|
+
}}
|
|
208
|
+
/>
|
|
209
|
+
)
|
|
210
|
+
}
|