realtimex-crm 0.14.2 → 0.14.3
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-CI9LYDGb.js} +2 -2
- package/dist/assets/{DealList-ypDCCiwg.js.map → DealList-CI9LYDGb.js.map} +1 -1
- package/dist/assets/{index-BzvO-53A.js → index-BOLCrrNA.js} +58 -58
- package/dist/assets/{index-BzvO-53A.js.map → index-BOLCrrNA.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/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/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.14.
|
|
3
|
+
"version": "0.14.3",
|
|
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",
|
|
@@ -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
|
+
};
|
|
@@ -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.';
|