realtimex-crm 0.8.1 → 0.9.1

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.
Files changed (29) hide show
  1. package/dist/assets/{DealList-B3alafQv.js → DealList-DnGVfS15.js} +2 -2
  2. package/dist/assets/{DealList-B3alafQv.js.map → DealList-DnGVfS15.js.map} +1 -1
  3. package/dist/assets/index-DPrpo5Xq.js +159 -0
  4. package/dist/assets/index-DPrpo5Xq.js.map +1 -0
  5. package/dist/assets/index-kM1Og1AS.css +1 -0
  6. package/dist/index.html +1 -1
  7. package/dist/stats.html +1 -1
  8. package/package.json +2 -1
  9. package/src/components/atomic-crm/integrations/ApiKeysTab.tsx +184 -0
  10. package/src/components/atomic-crm/integrations/CreateApiKeyDialog.tsx +217 -0
  11. package/src/components/atomic-crm/integrations/IntegrationsPage.tsx +34 -0
  12. package/src/components/atomic-crm/integrations/WebhooksTab.tsx +402 -0
  13. package/src/components/atomic-crm/layout/Header.tsx +14 -1
  14. package/src/components/atomic-crm/root/CRM.tsx +2 -0
  15. package/src/components/ui/alert-dialog.tsx +155 -0
  16. package/src/lib/api-key-utils.ts +22 -0
  17. package/supabase/functions/_shared/apiKeyAuth.ts +171 -0
  18. package/supabase/functions/_shared/webhookSignature.ts +23 -0
  19. package/supabase/functions/api-v1-activities/index.ts +137 -0
  20. package/supabase/functions/api-v1-companies/index.ts +166 -0
  21. package/supabase/functions/api-v1-contacts/index.ts +171 -0
  22. package/supabase/functions/api-v1-deals/index.ts +166 -0
  23. package/supabase/functions/webhook-dispatcher/index.ts +133 -0
  24. package/supabase/migrations/20251219120000_api_integrations.sql +133 -0
  25. package/supabase/migrations/20251219120100_webhook_triggers.sql +171 -0
  26. package/supabase/migrations/20251219120200_webhook_cron.sql +26 -0
  27. package/dist/assets/index-CHk72bAf.css +0 -1
  28. package/dist/assets/index-mhAjQ1_w.js +0 -153
  29. package/dist/assets/index-mhAjQ1_w.js.map +0 -1
@@ -0,0 +1,402 @@
1
+ import { useState } from "react";
2
+ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
3
+ import { useDataProvider, useNotify, useGetIdentity } from "ra-core";
4
+ import { useForm } from "react-hook-form";
5
+ import { generateApiKey } from "@/lib/api-key-utils";
6
+ import { Button } from "@/components/ui/button";
7
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
8
+ import { Plus, Trash2, Power, PowerOff } from "lucide-react";
9
+ import { Badge } from "@/components/ui/badge";
10
+ import { format } from "date-fns";
11
+ import {
12
+ Dialog,
13
+ DialogContent,
14
+ DialogDescription,
15
+ DialogHeader,
16
+ DialogTitle,
17
+ } from "@/components/ui/dialog";
18
+ import { Input } from "@/components/ui/input";
19
+ import { Label } from "@/components/ui/label";
20
+ import { Checkbox } from "@/components/ui/checkbox";
21
+ import {
22
+ AlertDialog,
23
+ AlertDialogAction,
24
+ AlertDialogCancel,
25
+ AlertDialogContent,
26
+ AlertDialogDescription,
27
+ AlertDialogFooter,
28
+ AlertDialogHeader,
29
+ AlertDialogTitle,
30
+ } from "@/components/ui/alert-dialog";
31
+
32
+ const AVAILABLE_EVENTS = [
33
+ { value: "contact.created", label: "Contact Created", category: "Contacts" },
34
+ { value: "contact.updated", label: "Contact Updated", category: "Contacts" },
35
+ { value: "contact.deleted", label: "Contact Deleted", category: "Contacts" },
36
+ { value: "company.created", label: "Company Created", category: "Companies" },
37
+ { value: "company.updated", label: "Company Updated", category: "Companies" },
38
+ { value: "company.deleted", label: "Company Deleted", category: "Companies" },
39
+ { value: "deal.created", label: "Deal Created", category: "Deals" },
40
+ { value: "deal.updated", label: "Deal Updated", category: "Deals" },
41
+ { value: "deal.deleted", label: "Deal Deleted", category: "Deals" },
42
+ {
43
+ value: "deal.stage_changed",
44
+ label: "Deal Stage Changed",
45
+ category: "Deals",
46
+ },
47
+ { value: "deal.won", label: "Deal Won", category: "Deals" },
48
+ { value: "deal.lost", label: "Deal Lost", category: "Deals" },
49
+ { value: "task.completed", label: "Task Completed", category: "Tasks" },
50
+ ];
51
+
52
+ export const WebhooksTab = () => {
53
+ const [showCreateDialog, setShowCreateDialog] = useState(false);
54
+ const [webhookToDelete, setWebhookToDelete] = useState<number | null>(null);
55
+ const dataProvider = useDataProvider();
56
+ const notify = useNotify();
57
+ const queryClient = useQueryClient();
58
+
59
+ const { data: webhooks, isLoading } = useQuery({
60
+ queryKey: ["webhooks"],
61
+ queryFn: async () => {
62
+ const { data } = await dataProvider.getList("webhooks", {
63
+ pagination: { page: 1, perPage: 100 },
64
+ sort: { field: "created_at", order: "DESC" },
65
+ filter: {},
66
+ });
67
+ return data;
68
+ },
69
+ });
70
+
71
+ const deleteMutation = useMutation({
72
+ mutationFn: async (id: number) => {
73
+ await dataProvider.delete("webhooks", { id, previousData: {} });
74
+ },
75
+ onSuccess: () => {
76
+ queryClient.invalidateQueries({ queryKey: ["webhooks"] });
77
+ notify("Webhook deleted successfully");
78
+ setWebhookToDelete(null);
79
+ },
80
+ onError: () => {
81
+ notify("Failed to delete webhook", { type: "error" });
82
+ },
83
+ });
84
+
85
+ const toggleMutation = useMutation({
86
+ mutationFn: async ({ id, is_active }: { id: number; is_active: boolean }) => {
87
+ await dataProvider.update("webhooks", {
88
+ id,
89
+ data: { is_active },
90
+ previousData: {},
91
+ });
92
+ },
93
+ onSuccess: () => {
94
+ queryClient.invalidateQueries({ queryKey: ["webhooks"] });
95
+ notify("Webhook updated successfully");
96
+ },
97
+ onError: () => {
98
+ notify("Failed to update webhook", { type: "error" });
99
+ },
100
+ });
101
+
102
+ return (
103
+ <div className="space-y-4">
104
+ <div className="flex justify-between items-center">
105
+ <p className="text-sm text-muted-foreground">
106
+ Webhooks notify external systems when events occur in your CRM.
107
+ </p>
108
+ <Button onClick={() => setShowCreateDialog(true)}>
109
+ <Plus className="h-4 w-4 mr-2" />
110
+ Create Webhook
111
+ </Button>
112
+ </div>
113
+
114
+ {isLoading ? (
115
+ <Card>
116
+ <CardContent className="py-8 text-center text-muted-foreground">
117
+ Loading...
118
+ </CardContent>
119
+ </Card>
120
+ ) : webhooks && webhooks.length > 0 ? (
121
+ <div className="space-y-3">
122
+ {webhooks.map((webhook: any) => (
123
+ <WebhookCard
124
+ key={webhook.id}
125
+ webhook={webhook}
126
+ onDelete={() => setWebhookToDelete(webhook.id)}
127
+ onToggle={() =>
128
+ toggleMutation.mutate({
129
+ id: webhook.id,
130
+ is_active: !webhook.is_active,
131
+ })
132
+ }
133
+ />
134
+ ))}
135
+ </div>
136
+ ) : (
137
+ <Card>
138
+ <CardContent className="py-12 text-center">
139
+ <p className="text-muted-foreground mb-4">No webhooks yet</p>
140
+ <Button onClick={() => setShowCreateDialog(true)}>
141
+ <Plus className="h-4 w-4 mr-2" />
142
+ Create your first webhook
143
+ </Button>
144
+ </CardContent>
145
+ </Card>
146
+ )}
147
+
148
+ <CreateWebhookDialog
149
+ open={showCreateDialog}
150
+ onClose={() => setShowCreateDialog(false)}
151
+ />
152
+
153
+ <AlertDialog
154
+ open={webhookToDelete !== null}
155
+ onOpenChange={() => setWebhookToDelete(null)}
156
+ >
157
+ <AlertDialogContent>
158
+ <AlertDialogHeader>
159
+ <AlertDialogTitle>Delete Webhook?</AlertDialogTitle>
160
+ <AlertDialogDescription>
161
+ This will permanently delete this webhook. No more events will be
162
+ sent to this endpoint. This action cannot be undone.
163
+ </AlertDialogDescription>
164
+ </AlertDialogHeader>
165
+ <AlertDialogFooter>
166
+ <AlertDialogCancel>Cancel</AlertDialogCancel>
167
+ <AlertDialogAction
168
+ onClick={() =>
169
+ webhookToDelete && deleteMutation.mutate(webhookToDelete)
170
+ }
171
+ className="bg-destructive hover:bg-destructive/90"
172
+ >
173
+ Delete
174
+ </AlertDialogAction>
175
+ </AlertDialogFooter>
176
+ </AlertDialogContent>
177
+ </AlertDialog>
178
+ </div>
179
+ );
180
+ };
181
+
182
+ const WebhookCard = ({
183
+ webhook,
184
+ onDelete,
185
+ onToggle,
186
+ }: {
187
+ webhook: any;
188
+ onDelete: () => void;
189
+ onToggle: () => void;
190
+ }) => {
191
+ return (
192
+ <Card>
193
+ <CardHeader className="pb-3">
194
+ <div className="flex justify-between items-start">
195
+ <div className="flex-1">
196
+ <CardTitle className="text-lg">{webhook.name}</CardTitle>
197
+ <p className="text-sm text-muted-foreground mt-1 break-all">
198
+ {webhook.url}
199
+ </p>
200
+ <div className="flex flex-wrap gap-1 mt-2">
201
+ {webhook.is_active ? (
202
+ <Badge variant="default">Active</Badge>
203
+ ) : (
204
+ <Badge variant="secondary">Inactive</Badge>
205
+ )}
206
+ {webhook.events &&
207
+ webhook.events.slice(0, 3).map((event: string) => (
208
+ <Badge key={event} variant="outline">
209
+ {event}
210
+ </Badge>
211
+ ))}
212
+ {webhook.events && webhook.events.length > 3 && (
213
+ <Badge variant="outline">+{webhook.events.length - 3} more</Badge>
214
+ )}
215
+ </div>
216
+ </div>
217
+ <div className="flex gap-2">
218
+ <Button variant="ghost" size="icon" onClick={onToggle}>
219
+ {webhook.is_active ? (
220
+ <PowerOff className="h-4 w-4" />
221
+ ) : (
222
+ <Power className="h-4 w-4" />
223
+ )}
224
+ </Button>
225
+ <Button variant="ghost" size="icon" onClick={onDelete}>
226
+ <Trash2 className="h-4 w-4 text-destructive" />
227
+ </Button>
228
+ </div>
229
+ </div>
230
+ </CardHeader>
231
+ <CardContent>
232
+ <div className="text-xs text-muted-foreground space-y-1">
233
+ <p>Created: {format(new Date(webhook.created_at), "PPP")}</p>
234
+ {webhook.last_triggered_at && (
235
+ <p>
236
+ Last triggered:{" "}
237
+ {format(new Date(webhook.last_triggered_at), "PPp")}
238
+ </p>
239
+ )}
240
+ {webhook.failure_count > 0 && (
241
+ <p className="text-destructive">
242
+ Failed deliveries: {webhook.failure_count}
243
+ </p>
244
+ )}
245
+ </div>
246
+ </CardContent>
247
+ </Card>
248
+ );
249
+ };
250
+
251
+ const CreateWebhookDialog = ({
252
+ open,
253
+ onClose,
254
+ }: {
255
+ open: boolean;
256
+ onClose: () => void;
257
+ }) => {
258
+ const dataProvider = useDataProvider();
259
+ const notify = useNotify();
260
+ const queryClient = useQueryClient();
261
+ const { identity } = useGetIdentity();
262
+
263
+ const { register, handleSubmit, watch, setValue, reset } = useForm({
264
+ defaultValues: {
265
+ name: "",
266
+ url: "",
267
+ events: [] as string[],
268
+ },
269
+ });
270
+
271
+ const createMutation = useMutation({
272
+ mutationFn: async (values: any) => {
273
+ // Generate a random secret for webhook signature
274
+ const secret = generateApiKey();
275
+
276
+ await dataProvider.create("webhooks", {
277
+ data: {
278
+ name: values.name,
279
+ url: values.url,
280
+ events: values.events,
281
+ is_active: true,
282
+ secret,
283
+ sales_id: identity?.id,
284
+ created_by_sales_id: identity?.id,
285
+ },
286
+ });
287
+ },
288
+ onSuccess: () => {
289
+ queryClient.invalidateQueries({ queryKey: ["webhooks"] });
290
+ notify("Webhook created successfully");
291
+ reset();
292
+ onClose();
293
+ },
294
+ onError: () => {
295
+ notify("Failed to create webhook", { type: "error" });
296
+ },
297
+ });
298
+
299
+ const toggleEvent = (event: string) => {
300
+ const currentEvents = watch("events");
301
+ if (currentEvents.includes(event)) {
302
+ setValue(
303
+ "events",
304
+ currentEvents.filter((e) => e !== event)
305
+ );
306
+ } else {
307
+ setValue("events", [...currentEvents, event]);
308
+ }
309
+ };
310
+
311
+ const handleClose = () => {
312
+ reset();
313
+ onClose();
314
+ };
315
+
316
+ // Group events by category
317
+ const eventsByCategory = AVAILABLE_EVENTS.reduce((acc, event) => {
318
+ if (!acc[event.category]) {
319
+ acc[event.category] = [];
320
+ }
321
+ acc[event.category].push(event);
322
+ return acc;
323
+ }, {} as Record<string, typeof AVAILABLE_EVENTS>);
324
+
325
+ return (
326
+ <Dialog open={open} onOpenChange={handleClose}>
327
+ <DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-y-auto">
328
+ <DialogHeader>
329
+ <DialogTitle>Create Webhook</DialogTitle>
330
+ <DialogDescription>
331
+ Create a new webhook to receive event notifications
332
+ </DialogDescription>
333
+ </DialogHeader>
334
+
335
+ <form
336
+ onSubmit={handleSubmit((values) => createMutation.mutate(values))}
337
+ >
338
+ <div className="space-y-4">
339
+ <div className="space-y-2">
340
+ <Label htmlFor="name">Name</Label>
341
+ <Input
342
+ id="name"
343
+ placeholder="e.g., Slack Notifications"
344
+ {...register("name", { required: true })}
345
+ />
346
+ </div>
347
+
348
+ <div className="space-y-2">
349
+ <Label htmlFor="url">Webhook URL</Label>
350
+ <Input
351
+ id="url"
352
+ type="url"
353
+ placeholder="https://example.com/webhook"
354
+ {...register("url", { required: true })}
355
+ />
356
+ </div>
357
+
358
+ <div className="space-y-2">
359
+ <Label>Events to Subscribe</Label>
360
+ <div className="space-y-3 max-h-60 overflow-y-auto border rounded-md p-3">
361
+ {Object.entries(eventsByCategory).map(([category, events]) => (
362
+ <div key={category}>
363
+ <p className="text-sm font-semibold mb-2">{category}</p>
364
+ <div className="space-y-2 ml-2">
365
+ {events.map((event) => (
366
+ <div
367
+ key={event.value}
368
+ className="flex items-center space-x-2"
369
+ >
370
+ <Checkbox
371
+ id={event.value}
372
+ checked={watch("events").includes(event.value)}
373
+ onCheckedChange={() => toggleEvent(event.value)}
374
+ />
375
+ <label
376
+ htmlFor={event.value}
377
+ className="text-sm cursor-pointer"
378
+ >
379
+ {event.label}
380
+ </label>
381
+ </div>
382
+ ))}
383
+ </div>
384
+ </div>
385
+ ))}
386
+ </div>
387
+ </div>
388
+
389
+ <div className="flex justify-end gap-2">
390
+ <Button type="button" variant="outline" onClick={handleClose}>
391
+ Cancel
392
+ </Button>
393
+ <Button type="submit" disabled={createMutation.isPending}>
394
+ Create
395
+ </Button>
396
+ </div>
397
+ </div>
398
+ </form>
399
+ </DialogContent>
400
+ </Dialog>
401
+ );
402
+ };
@@ -3,7 +3,7 @@ import {
3
3
  DropdownMenuLabel,
4
4
  DropdownMenuSeparator,
5
5
  } from "@/components/ui/dropdown-menu";
6
- import { Database, Settings, User } from "lucide-react";
6
+ import { Database, Settings, User, Webhook } from "lucide-react";
7
7
  import { CanAccess } from "ra-core";
8
8
  import { Link, matchPath, useLocation } from "react-router";
9
9
  import { RefreshButton } from "@/components/admin/refresh-button";
@@ -64,6 +64,7 @@ const Header = () => {
64
64
  <UserMenu>
65
65
  <ConfigurationMenu />
66
66
  <DatabaseMenu />
67
+ <IntegrationsMenu />
67
68
  <CanAccess resource="sales" action="list">
68
69
  <UsersMenu />
69
70
  </CanAccess>
@@ -165,4 +166,16 @@ const DatabaseMenu = () => {
165
166
  );
166
167
  };
167
168
 
169
+ const IntegrationsMenu = () => {
170
+ const { onClose } = useUserMenu() ?? {};
171
+ return (
172
+ <DropdownMenuItem asChild onClick={onClose}>
173
+ <Link to="/integrations" className="flex items-center gap-2">
174
+ <Webhook className="h-4 w-4" />
175
+ Integrations
176
+ </Link>
177
+ </DropdownMenuItem>
178
+ );
179
+ };
180
+
168
181
  export default Header;
@@ -26,6 +26,7 @@ import {
26
26
  import sales from "../sales";
27
27
  import { DatabasePage } from "../settings/DatabasePage";
28
28
  import { SettingsPage } from "../settings/SettingsPage";
29
+ import { IntegrationsPage } from "../integrations/IntegrationsPage";
29
30
  import type { ConfigurationContextValue } from "./ConfigurationContext";
30
31
  import { ConfigurationProvider } from "./ConfigurationContext";
31
32
  import {
@@ -163,6 +164,7 @@ export const CRM = ({
163
164
  <CustomRoutes>
164
165
  <Route path={SettingsPage.path} element={<SettingsPage />} />
165
166
  <Route path={DatabasePage.path} element={<DatabasePage />} />
167
+ <Route path={IntegrationsPage.path} element={<IntegrationsPage />} />
166
168
  </CustomRoutes>
167
169
  <Resource name="deals" {...deals} />
168
170
  <Resource name="contacts" {...contacts} />
@@ -0,0 +1,155 @@
1
+ import * as React from "react"
2
+ import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
3
+
4
+ import { cn } from "@/lib/utils"
5
+ import { buttonVariants } from "@/components/ui/button"
6
+
7
+ function AlertDialog({
8
+ ...props
9
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
10
+ return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
11
+ }
12
+
13
+ function AlertDialogTrigger({
14
+ ...props
15
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
16
+ return (
17
+ <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
18
+ )
19
+ }
20
+
21
+ function AlertDialogPortal({
22
+ ...props
23
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
24
+ return (
25
+ <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
26
+ )
27
+ }
28
+
29
+ function AlertDialogOverlay({
30
+ className,
31
+ ...props
32
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
33
+ return (
34
+ <AlertDialogPrimitive.Overlay
35
+ data-slot="alert-dialog-overlay"
36
+ className={cn(
37
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
38
+ className
39
+ )}
40
+ {...props}
41
+ />
42
+ )
43
+ }
44
+
45
+ function AlertDialogContent({
46
+ className,
47
+ ...props
48
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
49
+ return (
50
+ <AlertDialogPortal>
51
+ <AlertDialogOverlay />
52
+ <AlertDialogPrimitive.Content
53
+ data-slot="alert-dialog-content"
54
+ className={cn(
55
+ "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
56
+ className
57
+ )}
58
+ {...props}
59
+ />
60
+ </AlertDialogPortal>
61
+ )
62
+ }
63
+
64
+ function AlertDialogHeader({
65
+ className,
66
+ ...props
67
+ }: React.ComponentProps<"div">) {
68
+ return (
69
+ <div
70
+ data-slot="alert-dialog-header"
71
+ className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
72
+ {...props}
73
+ />
74
+ )
75
+ }
76
+
77
+ function AlertDialogFooter({
78
+ className,
79
+ ...props
80
+ }: React.ComponentProps<"div">) {
81
+ return (
82
+ <div
83
+ data-slot="alert-dialog-footer"
84
+ className={cn(
85
+ "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
86
+ className
87
+ )}
88
+ {...props}
89
+ />
90
+ )
91
+ }
92
+
93
+ function AlertDialogTitle({
94
+ className,
95
+ ...props
96
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
97
+ return (
98
+ <AlertDialogPrimitive.Title
99
+ data-slot="alert-dialog-title"
100
+ className={cn("text-lg font-semibold", className)}
101
+ {...props}
102
+ />
103
+ )
104
+ }
105
+
106
+ function AlertDialogDescription({
107
+ className,
108
+ ...props
109
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
110
+ return (
111
+ <AlertDialogPrimitive.Description
112
+ data-slot="alert-dialog-description"
113
+ className={cn("text-muted-foreground text-sm", className)}
114
+ {...props}
115
+ />
116
+ )
117
+ }
118
+
119
+ function AlertDialogAction({
120
+ className,
121
+ ...props
122
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
123
+ return (
124
+ <AlertDialogPrimitive.Action
125
+ className={cn(buttonVariants(), className)}
126
+ {...props}
127
+ />
128
+ )
129
+ }
130
+
131
+ function AlertDialogCancel({
132
+ className,
133
+ ...props
134
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
135
+ return (
136
+ <AlertDialogPrimitive.Cancel
137
+ className={cn(buttonVariants({ variant: "outline" }), className)}
138
+ {...props}
139
+ />
140
+ )
141
+ }
142
+
143
+ export {
144
+ AlertDialog,
145
+ AlertDialogPortal,
146
+ AlertDialogOverlay,
147
+ AlertDialogTrigger,
148
+ AlertDialogContent,
149
+ AlertDialogHeader,
150
+ AlertDialogFooter,
151
+ AlertDialogTitle,
152
+ AlertDialogDescription,
153
+ AlertDialogAction,
154
+ AlertDialogCancel,
155
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Generate a new API key in format: ak_live_<32 hex chars>
3
+ */
4
+ export function generateApiKey(): string {
5
+ const array = new Uint8Array(16);
6
+ crypto.getRandomValues(array);
7
+ const hex = Array.from(array)
8
+ .map((b) => b.toString(16).padStart(2, "0"))
9
+ .join("");
10
+ return `ak_live_${hex}`;
11
+ }
12
+
13
+ /**
14
+ * Hash API key using SHA-256
15
+ */
16
+ export async function hashApiKey(apiKey: string): Promise<string> {
17
+ const encoder = new TextEncoder();
18
+ const data = encoder.encode(apiKey);
19
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
20
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
21
+ return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
22
+ }