realtimex-crm 0.14.2 → 0.15.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/assets/{DealList-ypDCCiwg.js → DealList-RXXuszDo.js} +2 -2
- package/dist/assets/{DealList-ypDCCiwg.js.map → DealList-RXXuszDo.js.map} +1 -1
- package/dist/assets/{index-BzvO-53A.js → index-ClwLqEyJ.js} +58 -58
- package/dist/assets/{index-BzvO-53A.js.map → index-ClwLqEyJ.js.map} +1 -1
- package/dist/index.html +1 -1
- package/dist/stats.html +1 -1
- package/package.json +1 -1
- package/src/components/atomic-crm/companies/CompanyInputs.tsx +9 -14
- package/src/components/atomic-crm/integrations/ApiKeysTab.tsx +24 -10
- package/src/components/atomic-crm/integrations/CreateApiKeyDialog.tsx +3 -0
- package/src/components/atomic-crm/integrations/WebhooksTab.tsx +175 -2
- package/src/components/atomic-crm/root/CRM.tsx +12 -0
- package/src/components/atomic-crm/root/ConfigurationContext.tsx +18 -0
- package/src/lib/encryption-utils.ts +78 -0
- package/supabase/migrations/20251226052529_add_encrypted_key_to_api_keys.sql +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "realtimex-crm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "RealTimeX CRM - A full-featured CRM built with React, shadcn-admin-kit, and Supabase. Fork of Atomic CRM with RealTimeX App SDK integration.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -122,6 +122,7 @@ const CompanyContextInputs = () => {
|
|
|
122
122
|
companyLifecycleStages,
|
|
123
123
|
companyTypes,
|
|
124
124
|
companyRevenueRanges,
|
|
125
|
+
companyQualificationStatuses,
|
|
125
126
|
} = useConfigurationContext();
|
|
126
127
|
|
|
127
128
|
return (
|
|
@@ -145,6 +146,14 @@ const CompanyContextInputs = () => {
|
|
|
145
146
|
helperText={false}
|
|
146
147
|
/>
|
|
147
148
|
)}
|
|
149
|
+
{companyQualificationStatuses && (
|
|
150
|
+
<SelectInput
|
|
151
|
+
source="qualification_status"
|
|
152
|
+
label="Qualification Status"
|
|
153
|
+
choices={companyQualificationStatuses}
|
|
154
|
+
helperText={false}
|
|
155
|
+
/>
|
|
156
|
+
)}
|
|
148
157
|
|
|
149
158
|
{/* Industry & Sector */}
|
|
150
159
|
<SelectInput
|
|
@@ -235,7 +244,6 @@ const saleOptionRenderer = (choice: Sale) =>
|
|
|
235
244
|
|
|
236
245
|
const CompanyAdvancedSettings = () => {
|
|
237
246
|
const [isOpen, setIsOpen] = useState(false);
|
|
238
|
-
const { companyQualificationStatuses } = useConfigurationContext();
|
|
239
247
|
|
|
240
248
|
return (
|
|
241
249
|
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
|
@@ -267,19 +275,6 @@ const CompanyAdvancedSettings = () => {
|
|
|
267
275
|
helperText={false}
|
|
268
276
|
/>
|
|
269
277
|
</div>
|
|
270
|
-
|
|
271
|
-
{/* Data Quality */}
|
|
272
|
-
{companyQualificationStatuses && (
|
|
273
|
-
<div className="flex flex-col gap-4">
|
|
274
|
-
<h6 className="text-sm font-semibold text-muted-foreground">Data Quality</h6>
|
|
275
|
-
<SelectInput
|
|
276
|
-
source="qualification_status"
|
|
277
|
-
label="Qualification Status"
|
|
278
|
-
choices={companyQualificationStatuses}
|
|
279
|
-
helperText={false}
|
|
280
|
-
/>
|
|
281
|
-
</div>
|
|
282
|
-
)}
|
|
283
278
|
</div>
|
|
284
279
|
</CollapsibleContent>
|
|
285
280
|
</Collapsible>
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
3
|
-
import { useDataProvider, useNotify
|
|
3
|
+
import { useDataProvider, useNotify } from "ra-core";
|
|
4
4
|
import { Button } from "@/components/ui/button";
|
|
5
5
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
6
6
|
import { Plus, Trash2, Copy } from "lucide-react";
|
|
7
7
|
import { CreateApiKeyDialog } from "./CreateApiKeyDialog";
|
|
8
8
|
import { Badge } from "@/components/ui/badge";
|
|
9
9
|
import { format } from "date-fns";
|
|
10
|
+
import { decryptValue } from "@/lib/encryption-utils";
|
|
10
11
|
import {
|
|
11
12
|
AlertDialog,
|
|
12
13
|
AlertDialogAction,
|
|
@@ -24,7 +25,6 @@ export const ApiKeysTab = () => {
|
|
|
24
25
|
const dataProvider = useDataProvider();
|
|
25
26
|
const notify = useNotify();
|
|
26
27
|
const queryClient = useQueryClient();
|
|
27
|
-
const { identity: _identity } = useGetIdentity();
|
|
28
28
|
|
|
29
29
|
const { data: apiKeys, isLoading } = useQuery({
|
|
30
30
|
queryKey: ["api_keys"],
|
|
@@ -135,9 +135,18 @@ const ApiKeyCard = ({
|
|
|
135
135
|
}) => {
|
|
136
136
|
const notify = useNotify();
|
|
137
137
|
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
138
|
+
const copyFullKey = async () => {
|
|
139
|
+
try {
|
|
140
|
+
if (apiKey.encrypted_key) {
|
|
141
|
+
const fullKey = await decryptValue(apiKey.encrypted_key);
|
|
142
|
+
await navigator.clipboard.writeText(fullKey);
|
|
143
|
+
notify("Full API key copied to clipboard");
|
|
144
|
+
} else {
|
|
145
|
+
notify("API key not available for copying", { type: "warning" });
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
notify("Failed to copy API key", { type: "error" });
|
|
149
|
+
}
|
|
141
150
|
};
|
|
142
151
|
|
|
143
152
|
return (
|
|
@@ -163,11 +172,16 @@ const ApiKeyCard = ({
|
|
|
163
172
|
</div>
|
|
164
173
|
</CardHeader>
|
|
165
174
|
<CardContent className="space-y-2">
|
|
166
|
-
<div
|
|
167
|
-
<
|
|
168
|
-
|
|
169
|
-
<
|
|
170
|
-
|
|
175
|
+
<div>
|
|
176
|
+
<div className="flex items-center gap-2 font-mono text-sm bg-muted p-2 rounded">
|
|
177
|
+
<span className="flex-1">{apiKey.key_prefix}••••••••••••••••••••</span>
|
|
178
|
+
<Button variant="ghost" size="icon" onClick={copyFullKey}>
|
|
179
|
+
<Copy className="h-4 w-4" />
|
|
180
|
+
</Button>
|
|
181
|
+
</div>
|
|
182
|
+
<p className="text-xs text-muted-foreground mt-1">
|
|
183
|
+
Click copy to get the full unmasked key
|
|
184
|
+
</p>
|
|
171
185
|
</div>
|
|
172
186
|
<div className="text-xs text-muted-foreground space-y-1">
|
|
173
187
|
<p>Created: {format(new Date(apiKey.created_at), "PPP")}</p>
|
|
@@ -3,6 +3,7 @@ import { useForm } from "react-hook-form";
|
|
|
3
3
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
4
4
|
import { useDataProvider, useNotify, useGetIdentity } from "ra-core";
|
|
5
5
|
import { generateApiKey, hashApiKey } from "@/lib/api-key-utils";
|
|
6
|
+
import { encryptValue } from "@/lib/encryption-utils";
|
|
6
7
|
import {
|
|
7
8
|
Dialog,
|
|
8
9
|
DialogContent,
|
|
@@ -56,12 +57,14 @@ export const CreateApiKeyDialog = ({
|
|
|
56
57
|
const apiKey = generateApiKey();
|
|
57
58
|
const keyHash = await hashApiKey(apiKey);
|
|
58
59
|
const keyPrefix = apiKey.substring(0, 12);
|
|
60
|
+
const encryptedKey = await encryptValue(apiKey);
|
|
59
61
|
|
|
60
62
|
const { data } = await dataProvider.create("api_keys", {
|
|
61
63
|
data: {
|
|
62
64
|
name: values.name,
|
|
63
65
|
key_hash: keyHash,
|
|
64
66
|
key_prefix: keyPrefix,
|
|
67
|
+
encrypted_key: encryptedKey,
|
|
65
68
|
scopes: values.scopes,
|
|
66
69
|
is_active: true,
|
|
67
70
|
expires_at: values.expires_at || null,
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
1
|
+
import React, { useState } from "react";
|
|
2
2
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
3
3
|
import { useDataProvider, useNotify, useGetIdentity } from "ra-core";
|
|
4
4
|
import { useForm } from "react-hook-form";
|
|
5
5
|
import { generateApiKey } from "@/lib/api-key-utils";
|
|
6
6
|
import { Button } from "@/components/ui/button";
|
|
7
7
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
8
|
-
import { Plus, Trash2, Power, PowerOff } from "lucide-react";
|
|
8
|
+
import { Plus, Trash2, Power, PowerOff, Pencil } from "lucide-react";
|
|
9
9
|
import { Badge } from "@/components/ui/badge";
|
|
10
10
|
import { format } from "date-fns";
|
|
11
11
|
import {
|
|
@@ -61,6 +61,7 @@ const AVAILABLE_EVENTS = [
|
|
|
61
61
|
|
|
62
62
|
export const WebhooksTab = () => {
|
|
63
63
|
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
|
64
|
+
const [webhookToEdit, setWebhookToEdit] = useState<any | null>(null);
|
|
64
65
|
const [webhookToDelete, setWebhookToDelete] = useState<number | null>(null);
|
|
65
66
|
const dataProvider = useDataProvider();
|
|
66
67
|
const notify = useNotify();
|
|
@@ -133,6 +134,7 @@ export const WebhooksTab = () => {
|
|
|
133
134
|
<WebhookCard
|
|
134
135
|
key={webhook.id}
|
|
135
136
|
webhook={webhook}
|
|
137
|
+
onEdit={() => setWebhookToEdit(webhook)}
|
|
136
138
|
onDelete={() => setWebhookToDelete(webhook.id)}
|
|
137
139
|
onToggle={() =>
|
|
138
140
|
toggleMutation.mutate({
|
|
@@ -160,6 +162,12 @@ export const WebhooksTab = () => {
|
|
|
160
162
|
onClose={() => setShowCreateDialog(false)}
|
|
161
163
|
/>
|
|
162
164
|
|
|
165
|
+
<EditWebhookDialog
|
|
166
|
+
open={!!webhookToEdit}
|
|
167
|
+
webhook={webhookToEdit}
|
|
168
|
+
onClose={() => setWebhookToEdit(null)}
|
|
169
|
+
/>
|
|
170
|
+
|
|
163
171
|
<AlertDialog
|
|
164
172
|
open={webhookToDelete !== null}
|
|
165
173
|
onOpenChange={() => setWebhookToDelete(null)}
|
|
@@ -191,10 +199,12 @@ export const WebhooksTab = () => {
|
|
|
191
199
|
|
|
192
200
|
const WebhookCard = ({
|
|
193
201
|
webhook,
|
|
202
|
+
onEdit,
|
|
194
203
|
onDelete,
|
|
195
204
|
onToggle,
|
|
196
205
|
}: {
|
|
197
206
|
webhook: any;
|
|
207
|
+
onEdit: () => void;
|
|
198
208
|
onDelete: () => void;
|
|
199
209
|
onToggle: () => void;
|
|
200
210
|
}) => {
|
|
@@ -225,6 +235,9 @@ const WebhookCard = ({
|
|
|
225
235
|
</div>
|
|
226
236
|
</div>
|
|
227
237
|
<div className="flex gap-2">
|
|
238
|
+
<Button variant="ghost" size="icon" onClick={onEdit}>
|
|
239
|
+
<Pencil className="h-4 w-4" />
|
|
240
|
+
</Button>
|
|
228
241
|
<Button variant="ghost" size="icon" onClick={onToggle}>
|
|
229
242
|
{webhook.is_active ? (
|
|
230
243
|
<PowerOff className="h-4 w-4" />
|
|
@@ -410,3 +423,163 @@ const CreateWebhookDialog = ({
|
|
|
410
423
|
</Dialog>
|
|
411
424
|
);
|
|
412
425
|
};
|
|
426
|
+
|
|
427
|
+
const EditWebhookDialog = ({
|
|
428
|
+
open,
|
|
429
|
+
webhook,
|
|
430
|
+
onClose,
|
|
431
|
+
}: {
|
|
432
|
+
open: boolean;
|
|
433
|
+
webhook: any | null;
|
|
434
|
+
onClose: () => void;
|
|
435
|
+
}) => {
|
|
436
|
+
const dataProvider = useDataProvider();
|
|
437
|
+
const notify = useNotify();
|
|
438
|
+
const queryClient = useQueryClient();
|
|
439
|
+
|
|
440
|
+
const { register, handleSubmit, watch, setValue, reset } = useForm({
|
|
441
|
+
defaultValues: {
|
|
442
|
+
name: webhook?.name || "",
|
|
443
|
+
url: webhook?.url || "",
|
|
444
|
+
events: webhook?.events || ([] as string[]),
|
|
445
|
+
},
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// Update form when webhook changes
|
|
449
|
+
React.useEffect(() => {
|
|
450
|
+
if (webhook) {
|
|
451
|
+
setValue("name", webhook.name);
|
|
452
|
+
setValue("url", webhook.url);
|
|
453
|
+
setValue("events", webhook.events || []);
|
|
454
|
+
}
|
|
455
|
+
}, [webhook, setValue]);
|
|
456
|
+
|
|
457
|
+
const updateMutation = useMutation({
|
|
458
|
+
mutationFn: async (values: any) => {
|
|
459
|
+
await dataProvider.update("webhooks", {
|
|
460
|
+
id: webhook.id,
|
|
461
|
+
data: {
|
|
462
|
+
name: values.name,
|
|
463
|
+
url: values.url,
|
|
464
|
+
events: values.events,
|
|
465
|
+
},
|
|
466
|
+
previousData: webhook,
|
|
467
|
+
});
|
|
468
|
+
},
|
|
469
|
+
onSuccess: () => {
|
|
470
|
+
queryClient.invalidateQueries({ queryKey: ["webhooks"] });
|
|
471
|
+
notify("Webhook updated successfully");
|
|
472
|
+
reset();
|
|
473
|
+
onClose();
|
|
474
|
+
},
|
|
475
|
+
onError: () => {
|
|
476
|
+
notify("Failed to update webhook", { type: "error" });
|
|
477
|
+
},
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
const toggleEvent = (event: string) => {
|
|
481
|
+
const currentEvents = watch("events");
|
|
482
|
+
if (currentEvents.includes(event)) {
|
|
483
|
+
setValue(
|
|
484
|
+
"events",
|
|
485
|
+
currentEvents.filter((e) => e !== event)
|
|
486
|
+
);
|
|
487
|
+
} else {
|
|
488
|
+
setValue("events", [...currentEvents, event]);
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
const handleClose = () => {
|
|
493
|
+
reset();
|
|
494
|
+
onClose();
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
// Group events by category
|
|
498
|
+
const eventsByCategory = AVAILABLE_EVENTS.reduce((acc, event) => {
|
|
499
|
+
if (!acc[event.category]) {
|
|
500
|
+
acc[event.category] = [];
|
|
501
|
+
}
|
|
502
|
+
acc[event.category].push(event);
|
|
503
|
+
return acc;
|
|
504
|
+
}, {} as Record<string, typeof AVAILABLE_EVENTS>);
|
|
505
|
+
|
|
506
|
+
if (!webhook) return null;
|
|
507
|
+
|
|
508
|
+
return (
|
|
509
|
+
<Dialog open={open} onOpenChange={handleClose}>
|
|
510
|
+
<DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-y-auto">
|
|
511
|
+
<DialogHeader>
|
|
512
|
+
<DialogTitle>Edit Webhook</DialogTitle>
|
|
513
|
+
<DialogDescription>
|
|
514
|
+
Update webhook configuration
|
|
515
|
+
</DialogDescription>
|
|
516
|
+
</DialogHeader>
|
|
517
|
+
|
|
518
|
+
<form
|
|
519
|
+
onSubmit={handleSubmit((values) => updateMutation.mutate(values))}
|
|
520
|
+
>
|
|
521
|
+
<div className="space-y-4">
|
|
522
|
+
<div className="space-y-2">
|
|
523
|
+
<Label htmlFor="edit-name">Name</Label>
|
|
524
|
+
<Input
|
|
525
|
+
id="edit-name"
|
|
526
|
+
placeholder="e.g., Slack Notifications"
|
|
527
|
+
{...register("name", { required: true })}
|
|
528
|
+
/>
|
|
529
|
+
</div>
|
|
530
|
+
|
|
531
|
+
<div className="space-y-2">
|
|
532
|
+
<Label htmlFor="edit-url">Webhook URL</Label>
|
|
533
|
+
<Input
|
|
534
|
+
id="edit-url"
|
|
535
|
+
type="url"
|
|
536
|
+
placeholder="https://example.com/webhook"
|
|
537
|
+
{...register("url", { required: true })}
|
|
538
|
+
/>
|
|
539
|
+
</div>
|
|
540
|
+
|
|
541
|
+
<div className="space-y-2">
|
|
542
|
+
<Label>Events to Subscribe</Label>
|
|
543
|
+
<div className="space-y-3 max-h-60 overflow-y-auto border rounded-md p-3">
|
|
544
|
+
{Object.entries(eventsByCategory).map(([category, events]) => (
|
|
545
|
+
<div key={category}>
|
|
546
|
+
<p className="text-sm font-semibold mb-2">{category}</p>
|
|
547
|
+
<div className="space-y-2 ml-2">
|
|
548
|
+
{events.map((event) => (
|
|
549
|
+
<div
|
|
550
|
+
key={event.value}
|
|
551
|
+
className="flex items-center space-x-2"
|
|
552
|
+
>
|
|
553
|
+
<Checkbox
|
|
554
|
+
id={`edit-${event.value}`}
|
|
555
|
+
checked={watch("events").includes(event.value)}
|
|
556
|
+
onCheckedChange={() => toggleEvent(event.value)}
|
|
557
|
+
/>
|
|
558
|
+
<label
|
|
559
|
+
htmlFor={`edit-${event.value}`}
|
|
560
|
+
className="text-sm cursor-pointer"
|
|
561
|
+
>
|
|
562
|
+
{event.label}
|
|
563
|
+
</label>
|
|
564
|
+
</div>
|
|
565
|
+
))}
|
|
566
|
+
</div>
|
|
567
|
+
</div>
|
|
568
|
+
))}
|
|
569
|
+
</div>
|
|
570
|
+
</div>
|
|
571
|
+
|
|
572
|
+
<div className="flex justify-end gap-2">
|
|
573
|
+
<Button type="button" variant="outline" onClick={handleClose}>
|
|
574
|
+
Cancel
|
|
575
|
+
</Button>
|
|
576
|
+
<Button type="submit" disabled={updateMutation.isPending}>
|
|
577
|
+
Update
|
|
578
|
+
</Button>
|
|
579
|
+
</div>
|
|
580
|
+
</div>
|
|
581
|
+
</form>
|
|
582
|
+
</DialogContent>
|
|
583
|
+
</Dialog>
|
|
584
|
+
);
|
|
585
|
+
};
|
|
@@ -99,6 +99,10 @@ export type CRMProps = {
|
|
|
99
99
|
export const CRM = ({
|
|
100
100
|
contactGender = defaultContactGender,
|
|
101
101
|
companySectors = defaultCompanySectors,
|
|
102
|
+
companyLifecycleStages,
|
|
103
|
+
companyTypes,
|
|
104
|
+
companyQualificationStatuses,
|
|
105
|
+
companyRevenueRanges,
|
|
102
106
|
dealCategories = defaultDealCategories,
|
|
103
107
|
dealPipelineStatuses = defaultDealPipelineStatuses,
|
|
104
108
|
dealStages = defaultDealStages,
|
|
@@ -109,6 +113,8 @@ export const CRM = ({
|
|
|
109
113
|
taskPriorities = defaultTaskPriorities,
|
|
110
114
|
taskStatuses = defaultTaskStatuses,
|
|
111
115
|
title = defaultTitle,
|
|
116
|
+
externalHeartbeatStatuses,
|
|
117
|
+
internalHeartbeatStatuses,
|
|
112
118
|
dataProvider = defaultDataProvider,
|
|
113
119
|
authProvider = defaultAuthProvider,
|
|
114
120
|
disableTelemetry,
|
|
@@ -132,6 +138,10 @@ export const CRM = ({
|
|
|
132
138
|
<ConfigurationProvider
|
|
133
139
|
contactGender={contactGender}
|
|
134
140
|
companySectors={companySectors}
|
|
141
|
+
companyLifecycleStages={companyLifecycleStages}
|
|
142
|
+
companyTypes={companyTypes}
|
|
143
|
+
companyQualificationStatuses={companyQualificationStatuses}
|
|
144
|
+
companyRevenueRanges={companyRevenueRanges}
|
|
135
145
|
dealCategories={dealCategories}
|
|
136
146
|
dealPipelineStatuses={dealPipelineStatuses}
|
|
137
147
|
dealStages={dealStages}
|
|
@@ -142,6 +152,8 @@ export const CRM = ({
|
|
|
142
152
|
taskPriorities={taskPriorities}
|
|
143
153
|
taskStatuses={taskStatuses}
|
|
144
154
|
title={title}
|
|
155
|
+
externalHeartbeatStatuses={externalHeartbeatStatuses}
|
|
156
|
+
internalHeartbeatStatuses={internalHeartbeatStatuses}
|
|
145
157
|
>
|
|
146
158
|
<DatabaseHealthCheck dataProvider={dataProvider}>
|
|
147
159
|
<Admin
|
|
@@ -19,6 +19,10 @@ import {
|
|
|
19
19
|
// Define types for the context value
|
|
20
20
|
export interface ConfigurationContextValue {
|
|
21
21
|
companySectors: string[];
|
|
22
|
+
companyLifecycleStages?: { id: string; name: string }[];
|
|
23
|
+
companyTypes?: { id: string; name: string }[];
|
|
24
|
+
companyQualificationStatuses?: { id: string; name: string }[];
|
|
25
|
+
companyRevenueRanges?: { id: string; name: string }[];
|
|
22
26
|
dealCategories: string[];
|
|
23
27
|
dealPipelineStatuses: string[];
|
|
24
28
|
dealStages: DealStage[];
|
|
@@ -30,6 +34,8 @@ export interface ConfigurationContextValue {
|
|
|
30
34
|
darkModeLogo: string;
|
|
31
35
|
lightModeLogo: string;
|
|
32
36
|
contactGender: ContactGender[];
|
|
37
|
+
externalHeartbeatStatuses?: { id: string; name: string; color: string }[];
|
|
38
|
+
internalHeartbeatStatuses?: { id: string; name: string; color: string }[];
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
export interface ConfigurationProviderProps extends ConfigurationContextValue {
|
|
@@ -55,6 +61,10 @@ export const ConfigurationContext = createContext<ConfigurationContextValue>({
|
|
|
55
61
|
export const ConfigurationProvider = ({
|
|
56
62
|
children,
|
|
57
63
|
companySectors,
|
|
64
|
+
companyLifecycleStages,
|
|
65
|
+
companyTypes,
|
|
66
|
+
companyQualificationStatuses,
|
|
67
|
+
companyRevenueRanges,
|
|
58
68
|
dealCategories,
|
|
59
69
|
dealPipelineStatuses,
|
|
60
70
|
dealStages,
|
|
@@ -66,10 +76,16 @@ export const ConfigurationProvider = ({
|
|
|
66
76
|
taskStatuses,
|
|
67
77
|
title,
|
|
68
78
|
contactGender,
|
|
79
|
+
externalHeartbeatStatuses,
|
|
80
|
+
internalHeartbeatStatuses,
|
|
69
81
|
}: ConfigurationProviderProps) => (
|
|
70
82
|
<ConfigurationContext.Provider
|
|
71
83
|
value={{
|
|
72
84
|
companySectors,
|
|
85
|
+
companyLifecycleStages,
|
|
86
|
+
companyTypes,
|
|
87
|
+
companyQualificationStatuses,
|
|
88
|
+
companyRevenueRanges,
|
|
73
89
|
dealCategories,
|
|
74
90
|
dealPipelineStatuses,
|
|
75
91
|
dealStages,
|
|
@@ -81,6 +97,8 @@ export const ConfigurationProvider = ({
|
|
|
81
97
|
taskPriorities,
|
|
82
98
|
taskStatuses,
|
|
83
99
|
contactGender,
|
|
100
|
+
externalHeartbeatStatuses,
|
|
101
|
+
internalHeartbeatStatuses,
|
|
84
102
|
}}
|
|
85
103
|
>
|
|
86
104
|
{children}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple encryption utilities for storing sensitive data
|
|
3
|
+
* Uses Web Crypto API for encryption/decryption
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Get or generate encryption key
|
|
7
|
+
// In production, this should be stored securely (e.g., environment variable)
|
|
8
|
+
// For now, we'll use a derivation from a fixed string + user context
|
|
9
|
+
const getEncryptionKey = async (): Promise<CryptoKey> => {
|
|
10
|
+
const keyMaterial = await crypto.subtle.importKey(
|
|
11
|
+
"raw",
|
|
12
|
+
new TextEncoder().encode(
|
|
13
|
+
// In production, use a secure environment variable
|
|
14
|
+
import.meta.env.VITE_ENCRYPTION_KEY || "atomic-crm-default-key-change-in-production"
|
|
15
|
+
),
|
|
16
|
+
"PBKDF2",
|
|
17
|
+
false,
|
|
18
|
+
["deriveBits", "deriveKey"]
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
return crypto.subtle.deriveKey(
|
|
22
|
+
{
|
|
23
|
+
name: "PBKDF2",
|
|
24
|
+
salt: new TextEncoder().encode("atomic-crm-salt"),
|
|
25
|
+
iterations: 100000,
|
|
26
|
+
hash: "SHA-256",
|
|
27
|
+
},
|
|
28
|
+
keyMaterial,
|
|
29
|
+
{ name: "AES-GCM", length: 256 },
|
|
30
|
+
false,
|
|
31
|
+
["encrypt", "decrypt"]
|
|
32
|
+
);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Encrypt a string value
|
|
37
|
+
*/
|
|
38
|
+
export const encryptValue = async (value: string): Promise<string> => {
|
|
39
|
+
const key = await getEncryptionKey();
|
|
40
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
41
|
+
const encoded = new TextEncoder().encode(value);
|
|
42
|
+
|
|
43
|
+
const encrypted = await crypto.subtle.encrypt(
|
|
44
|
+
{ name: "AES-GCM", iv },
|
|
45
|
+
key,
|
|
46
|
+
encoded
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// Combine IV and encrypted data
|
|
50
|
+
const combined = new Uint8Array(iv.length + encrypted.byteLength);
|
|
51
|
+
combined.set(iv);
|
|
52
|
+
combined.set(new Uint8Array(encrypted), iv.length);
|
|
53
|
+
|
|
54
|
+
// Convert to base64
|
|
55
|
+
return btoa(String.fromCharCode(...combined));
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Decrypt an encrypted string value
|
|
60
|
+
*/
|
|
61
|
+
export const decryptValue = async (encryptedValue: string): Promise<string> => {
|
|
62
|
+
const key = await getEncryptionKey();
|
|
63
|
+
|
|
64
|
+
// Decode from base64
|
|
65
|
+
const combined = Uint8Array.from(atob(encryptedValue), (c) => c.charCodeAt(0));
|
|
66
|
+
|
|
67
|
+
// Extract IV and encrypted data
|
|
68
|
+
const iv = combined.slice(0, 12);
|
|
69
|
+
const encrypted = combined.slice(12);
|
|
70
|
+
|
|
71
|
+
const decrypted = await crypto.subtle.decrypt(
|
|
72
|
+
{ name: "AES-GCM", iv },
|
|
73
|
+
key,
|
|
74
|
+
encrypted
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
return new TextDecoder().decode(decrypted);
|
|
78
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-- Add encrypted_key column to api_keys table to allow key retrieval
|
|
2
|
+
-- This improves UX by letting users copy their keys after creation
|
|
3
|
+
-- Keys are encrypted at rest for security
|
|
4
|
+
|
|
5
|
+
-- Add column to store encrypted API key
|
|
6
|
+
ALTER TABLE public.api_keys
|
|
7
|
+
ADD COLUMN IF NOT EXISTS encrypted_key text;
|
|
8
|
+
|
|
9
|
+
COMMENT ON COLUMN public.api_keys.encrypted_key IS 'Encrypted full API key for retrieval. Encrypted using application-level encryption.';
|