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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "realtimex-crm",
3
- "version": "0.14.2",
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, 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
+ };
@@ -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.';