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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "realtimex-crm",
3
- "version": "0.14.2",
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, useGetIdentity } from "ra-core";
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 copyKey = () => {
139
- navigator.clipboard.writeText(apiKey.key_prefix + "••••••••");
140
- notify("Key prefix copied (full key only shown once at creation)");
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 className="flex items-center gap-2 font-mono text-sm bg-muted p-2 rounded">
167
- <span>{apiKey.key_prefix}••••••••••••••••••••</span>
168
- <Button variant="ghost" size="icon" onClick={copyKey}>
169
- <Copy className="h-4 w-4" />
170
- </Button>
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.';