realtimex-crm 0.5.5 → 0.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "realtimex-crm",
3
- "version": "0.5.5",
3
+ "version": "0.6.1",
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",
@@ -89,12 +89,20 @@ export const LoginPage = (props: { redirectTo?: string }) => {
89
89
  </Button>
90
90
  </Form>
91
91
 
92
- <Link
93
- to={"/forgot-password"}
94
- className="text-sm text-center hover:underline"
95
- >
96
- Forgot your password?
97
- </Link>
92
+ <div className="flex flex-col gap-2 text-sm text-center">
93
+ <Link
94
+ to={"/forgot-password"}
95
+ className="hover:underline"
96
+ >
97
+ Forgot your password?
98
+ </Link>
99
+ <Link
100
+ to={"/otp-login"}
101
+ className="hover:underline"
102
+ >
103
+ Login with email code (OTP)
104
+ </Link>
105
+ </div>
98
106
  </div>
99
107
  </div>
100
108
  </div>
@@ -59,6 +59,20 @@ export const authProvider: AuthProvider = {
59
59
  ) {
60
60
  return;
61
61
  }
62
+ // Users are on the change-password page, nothing to do
63
+ if (
64
+ window.location.pathname === "/change-password" ||
65
+ window.location.hash.includes("#/change-password")
66
+ ) {
67
+ return;
68
+ }
69
+ // Users are on the otp-login page, nothing to do
70
+ if (
71
+ window.location.pathname === "/otp-login" ||
72
+ window.location.hash.includes("#/otp-login")
73
+ ) {
74
+ return;
75
+ }
62
76
  // Users are on the sign-up page, nothing to do
63
77
  if (
64
78
  window.location.pathname === "/sign-up" ||
@@ -105,19 +105,15 @@ const dataProviderWithCustomMethods = {
105
105
  },
106
106
 
107
107
  async signUp({ email, password, first_name, last_name }: SignUpData) {
108
- const response = await supabase.auth.signUp({
109
- email,
110
- password,
111
- options: {
112
- data: {
113
- first_name,
114
- last_name,
115
- },
116
- },
108
+ // Use admin API via edge function to create first user
109
+ // This bypasses the signup restriction (enable_signup = false)
110
+ const { data, error } = await supabase.functions.invoke("setup", {
111
+ method: "POST",
112
+ body: { email, password, first_name, last_name },
117
113
  });
118
114
 
119
- if (!response.data?.user || response.error) {
120
- console.error("signUp.error", response.error);
115
+ if (!data || error) {
116
+ console.error("signUp.error", error);
121
117
  throw new Error("Failed to create account");
122
118
  }
123
119
 
@@ -125,7 +121,7 @@ const dataProviderWithCustomMethods = {
125
121
  getIsInitialized._is_initialized_cache = true;
126
122
 
127
123
  return {
128
- id: response.data.user.id,
124
+ id: data.data.id,
129
125
  email,
130
126
  password,
131
127
  };
@@ -173,6 +169,44 @@ const dataProviderWithCustomMethods = {
173
169
 
174
170
  return data;
175
171
  },
172
+ async resendInvite(id: Identifier) {
173
+ const { data: sale, error } = await supabase.functions.invoke<Sale>(
174
+ "users",
175
+ {
176
+ method: "PUT",
177
+ body: {
178
+ sales_id: id,
179
+ action: "invite",
180
+ },
181
+ },
182
+ );
183
+
184
+ if (!sale || error) {
185
+ console.error("resendInvite.error", error);
186
+ throw new Error("Failed to resend invitation");
187
+ }
188
+
189
+ return sale;
190
+ },
191
+ async resetPassword(id: Identifier) {
192
+ const { data: sale, error } = await supabase.functions.invoke<Sale>(
193
+ "users",
194
+ {
195
+ method: "PUT",
196
+ body: {
197
+ sales_id: id,
198
+ action: "reset",
199
+ },
200
+ },
201
+ );
202
+
203
+ if (!sale || error) {
204
+ console.error("resetPassword.error", error);
205
+ throw new Error("Failed to send password reset");
206
+ }
207
+
208
+ return sale;
209
+ },
176
210
  async updatePassword(id: Identifier) {
177
211
  const { data: passwordUpdated, error } =
178
212
  await supabase.functions.invoke<boolean>("updatePassword", {
@@ -10,6 +10,8 @@ import { Route } from "react-router";
10
10
  import { Admin } from "@/components/admin/admin";
11
11
  import { ForgotPasswordPage } from "@/components/supabase/forgot-password-page";
12
12
  import { SetPasswordPage } from "@/components/supabase/set-password-page";
13
+ import { ChangePasswordPage } from "@/components/supabase/change-password-page";
14
+ import { OtpLoginPage } from "@/components/supabase/otp-login-page";
13
15
 
14
16
  import companies from "../companies";
15
17
  import contacts from "../contacts";
@@ -151,6 +153,11 @@ export const CRM = ({
151
153
  path={ForgotPasswordPage.path}
152
154
  element={<ForgotPasswordPage />}
153
155
  />
156
+ <Route
157
+ path={ChangePasswordPage.path}
158
+ element={<ChangePasswordPage />}
159
+ />
160
+ <Route path={OtpLoginPage.path} element={<OtpLoginPage />} />
154
161
  </CustomRoutes>
155
162
 
156
163
  <CustomRoutes>
@@ -1,15 +1,43 @@
1
- import { useGetIdentity, useListContext, useRecordContext } from "ra-core";
1
+ import { useMutation } from "@tanstack/react-query";
2
+ import { KeyRound, MoreHorizontal, RefreshCw } from "lucide-react";
3
+ import * as React from "react";
4
+ import {
5
+ useDataProvider,
6
+ useGetIdentity,
7
+ useListContext,
8
+ useNotify,
9
+ useRecordContext,
10
+ useRefresh,
11
+ } from "ra-core";
2
12
  import { CreateButton } from "@/components/admin/create-button";
3
13
  import { DataTable } from "@/components/admin/data-table";
4
14
  import { ExportButton } from "@/components/admin/export-button";
5
15
  import { List } from "@/components/admin/list";
6
16
  import { SearchInput } from "@/components/admin/search-input";
7
17
  import { Badge } from "@/components/ui/badge";
18
+ import { Button } from "@/components/ui/button";
8
19
  import { Card } from "@/components/ui/card";
20
+ import {
21
+ Dialog,
22
+ DialogContent,
23
+ DialogDescription,
24
+ DialogFooter,
25
+ DialogHeader,
26
+ DialogTitle,
27
+ } from "@/components/ui/dialog";
28
+ import {
29
+ DropdownMenu,
30
+ DropdownMenuContent,
31
+ DropdownMenuItem,
32
+ DropdownMenuLabel,
33
+ DropdownMenuSeparator,
34
+ DropdownMenuTrigger,
35
+ } from "@/components/ui/dropdown-menu";
9
36
  import { Skeleton } from "@/components/ui/skeleton";
10
37
 
11
38
  import { TopToolbar } from "../layout/TopToolbar";
12
39
  import useAppBarHeight from "../misc/useAppBarHeight";
40
+ import type { CrmDataProvider } from "../providers/types";
13
41
 
14
42
  const SalesListActions = () => (
15
43
  <TopToolbar>
@@ -45,6 +73,174 @@ const OptionsField = (_props: { label?: string | boolean }) => {
45
73
  );
46
74
  };
47
75
 
76
+ const RowActions = () => {
77
+ const record = useRecordContext();
78
+ const dataProvider = useDataProvider<CrmDataProvider>();
79
+ const notify = useNotify();
80
+ const refresh = useRefresh();
81
+ const [inviteDialogOpen, setInviteDialogOpen] = React.useState(false);
82
+ const [resetDialogOpen, setResetDialogOpen] = React.useState(false);
83
+
84
+ const { mutate: resendInvite, isPending: isInvitePending } = useMutation({
85
+ mutationFn: async () => {
86
+ return dataProvider.resendInvite(record.id);
87
+ },
88
+ onSuccess: () => {
89
+ notify("Invitation email resent successfully");
90
+ setInviteDialogOpen(false);
91
+ refresh();
92
+ },
93
+ onError: () => {
94
+ notify("Failed to resend invitation email", { type: "error" });
95
+ setInviteDialogOpen(false);
96
+ },
97
+ });
98
+
99
+ const { mutate: resetPassword, isPending: isResetPending } = useMutation({
100
+ mutationFn: async () => {
101
+ return dataProvider.resetPassword(record.id);
102
+ },
103
+ onSuccess: () => {
104
+ notify("Password reset email sent successfully");
105
+ setResetDialogOpen(false);
106
+ refresh();
107
+ },
108
+ onError: () => {
109
+ notify("Failed to send password reset email", { type: "error" });
110
+ setResetDialogOpen(false);
111
+ },
112
+ });
113
+
114
+ if (!record) return null;
115
+
116
+ // Determine which action to show based on confirmation status
117
+ const isConfirmed = record.email_confirmed_at !== null;
118
+ const isDisabled = record.disabled === true;
119
+
120
+ console.log("[RowActions] Record:", {
121
+ email: record.email,
122
+ email_confirmed_at: record.email_confirmed_at,
123
+ isConfirmed,
124
+ isDisabled,
125
+ inviteDialogOpen,
126
+ resetDialogOpen,
127
+ });
128
+
129
+ return (
130
+ <>
131
+ <DropdownMenu>
132
+ <DropdownMenuTrigger asChild>
133
+ <Button variant="ghost" className="h-8 w-8 p-0">
134
+ <span className="sr-only">Open menu</span>
135
+ <MoreHorizontal className="h-4 w-4" />
136
+ </Button>
137
+ </DropdownMenuTrigger>
138
+ <DropdownMenuContent align="end">
139
+ <DropdownMenuLabel>Email Actions</DropdownMenuLabel>
140
+ <DropdownMenuSeparator />
141
+ {!isConfirmed && (
142
+ <DropdownMenuItem
143
+ onClick={(e) => {
144
+ e.preventDefault();
145
+ e.stopPropagation();
146
+ console.log("[Menu] Clicked Resend Invite");
147
+ setInviteDialogOpen(true);
148
+ }}
149
+ >
150
+ <RefreshCw className="mr-2 h-4 w-4" />
151
+ Resend Invite
152
+ </DropdownMenuItem>
153
+ )}
154
+ {isConfirmed && !isDisabled && (
155
+ <DropdownMenuItem onClick={() => setResetDialogOpen(true)}>
156
+ <KeyRound className="mr-2 h-4 w-4" />
157
+ Send Password Reset
158
+ </DropdownMenuItem>
159
+ )}
160
+ </DropdownMenuContent>
161
+ </DropdownMenu>
162
+
163
+ {/* Resend Invite Dialog */}
164
+ <Dialog open={inviteDialogOpen} onOpenChange={setInviteDialogOpen}>
165
+ <DialogContent
166
+ onClick={(e) => e.stopPropagation()}
167
+ onPointerDown={(e) => e.stopPropagation()}
168
+ >
169
+ <DialogHeader>
170
+ <DialogTitle>Resend Invitation</DialogTitle>
171
+ <DialogDescription>
172
+ Send a new invitation email to <strong>{record.email}</strong>?
173
+ <br />
174
+ <br />
175
+ This will send them a fresh invitation link to set up their account.
176
+ </DialogDescription>
177
+ </DialogHeader>
178
+ <DialogFooter>
179
+ <Button
180
+ variant="outline"
181
+ onClick={(e) => {
182
+ e.stopPropagation();
183
+ setInviteDialogOpen(false);
184
+ }}
185
+ disabled={isInvitePending}
186
+ >
187
+ Cancel
188
+ </Button>
189
+ <Button
190
+ onClick={(e) => {
191
+ e.stopPropagation();
192
+ resendInvite();
193
+ }}
194
+ disabled={isInvitePending}
195
+ >
196
+ {isInvitePending ? "Sending..." : "Send Invitation"}
197
+ </Button>
198
+ </DialogFooter>
199
+ </DialogContent>
200
+ </Dialog>
201
+
202
+ {/* Password Reset Dialog */}
203
+ <Dialog open={resetDialogOpen} onOpenChange={setResetDialogOpen}>
204
+ <DialogContent
205
+ onClick={(e) => e.stopPropagation()}
206
+ onPointerDown={(e) => e.stopPropagation()}
207
+ >
208
+ <DialogHeader>
209
+ <DialogTitle>Send Password Reset</DialogTitle>
210
+ <DialogDescription>
211
+ Send a password reset email to <strong>{record.email}</strong>?
212
+ <br />
213
+ <br />
214
+ This will send them a link to reset their password.
215
+ </DialogDescription>
216
+ </DialogHeader>
217
+ <DialogFooter>
218
+ <Button
219
+ variant="outline"
220
+ onClick={(e) => {
221
+ e.stopPropagation();
222
+ setResetDialogOpen(false);
223
+ }}
224
+ disabled={isResetPending}
225
+ >
226
+ Cancel
227
+ </Button>
228
+ <Button
229
+ onClick={(e) => {
230
+ e.stopPropagation();
231
+ resetPassword();
232
+ }}
233
+ disabled={isResetPending}
234
+ >
235
+ {isResetPending ? "Sending..." : "Send Reset Link"}
236
+ </Button>
237
+ </DialogFooter>
238
+ </DialogContent>
239
+ </Dialog>
240
+ </>
241
+ );
242
+ };
243
+
48
244
  export function SalesList() {
49
245
  return (
50
246
  <List
@@ -91,6 +287,9 @@ const SalesListLayout = () => {
91
287
  <DataTable.Col label={false}>
92
288
  <OptionsField />
93
289
  </DataTable.Col>
290
+ <DataTable.Col label={false}>
291
+ <RowActions />
292
+ </DataTable.Col>
94
293
  </DataTable>
95
294
  );
96
295
  };
@@ -10,6 +10,7 @@ import {
10
10
  } from "ra-core";
11
11
  import { useState } from "react";
12
12
  import { useFormState } from "react-hook-form";
13
+ import { useNavigate } from "react-router";
13
14
  import { RecordField } from "@/components/admin/record-field";
14
15
  import { TextInput } from "@/components/admin/text-input";
15
16
  import { Button } from "@/components/ui/button";
@@ -78,28 +79,14 @@ const SettingsForm = ({
78
79
  setEditMode: (value: boolean) => void;
79
80
  }) => {
80
81
  const notify = useNotify();
82
+ const navigate = useNavigate();
81
83
  const record = useRecordContext<Sale>();
82
84
  const { identity, refetch } = useGetIdentity();
83
85
  const { isDirty } = useFormState();
84
86
  const dataProvider = useDataProvider<CrmDataProvider>();
85
87
 
86
- const { mutate: updatePassword } = useMutation({
87
- mutationKey: ["updatePassword"],
88
- mutationFn: async () => {
89
- if (!identity) {
90
- throw new Error("Record not found");
91
- }
92
- return dataProvider.updatePassword(identity.id);
93
- },
94
- onSuccess: () => {
95
- notify("A reset password email has been sent to your email address");
96
- },
97
- onError: (e) => {
98
- notify(`${e}`, {
99
- type: "error",
100
- });
101
- },
102
- });
88
+ // Removed old updatePassword mutation - now uses OTP flow
89
+ // Users will be redirected to forgot-password page which sends OTP
103
90
 
104
91
  const { mutate: mutateSale } = useMutation({
105
92
  mutationKey: ["signup"],
@@ -120,7 +107,8 @@ const SettingsForm = ({
120
107
  if (!identity) return null;
121
108
 
122
109
  const handleClickOpenPasswordChange = () => {
123
- updatePassword();
110
+ // Redirect to forgot-password page which uses OTP flow
111
+ navigate('/forgot-password');
124
112
  };
125
113
 
126
114
  const handleAvatarUpdate = async (values: any) => {
@@ -0,0 +1,119 @@
1
+ import { useState } from "react";
2
+ import { Form, required, useNotify, useTranslate } from "ra-core";
3
+ import { Layout } from "@/components/supabase/layout";
4
+ import type { FieldValues, SubmitHandler } from "react-hook-form";
5
+ import { TextInput } from "@/components/admin/text-input";
6
+ import { Button } from "@/components/ui/button";
7
+ import { supabase } from "@/components/atomic-crm/providers/supabase/supabase";
8
+ import { useNavigate } from "react-router";
9
+
10
+ interface FormData {
11
+ password: string;
12
+ confirm: string;
13
+ }
14
+
15
+ export const ChangePasswordPage = () => {
16
+ const [loading, setLoading] = useState(false);
17
+
18
+ const notify = useNotify();
19
+ const translate = useTranslate();
20
+ const navigate = useNavigate();
21
+
22
+ const validate = (values: FormData) => {
23
+ const errors: Record<string, string> = {};
24
+
25
+ if (!values.password) {
26
+ errors.password = translate("ra.validation.required");
27
+ } else if (values.password.length < 6) {
28
+ errors.password = "Password must be at least 6 characters";
29
+ }
30
+
31
+ if (!values.confirm) {
32
+ errors.confirm = translate("ra.validation.required");
33
+ } else if (values.password !== values.confirm) {
34
+ errors.confirm = "Passwords do not match";
35
+ }
36
+
37
+ return errors;
38
+ };
39
+
40
+ const submit = async (values: FormData) => {
41
+ try {
42
+ setLoading(true);
43
+
44
+ const { error } = await supabase.auth.updateUser({
45
+ password: values.password,
46
+ });
47
+
48
+ if (error) {
49
+ throw error;
50
+ }
51
+
52
+ notify("Password updated successfully", { type: "success" });
53
+ navigate("/");
54
+ } catch (error: any) {
55
+ notify(
56
+ typeof error === "string"
57
+ ? error
58
+ : typeof error === "undefined" || !error.message
59
+ ? "Failed to update password"
60
+ : error.message,
61
+ {
62
+ type: "warning",
63
+ messageArgs: {
64
+ _:
65
+ typeof error === "string"
66
+ ? error
67
+ : error && error.message
68
+ ? error.message
69
+ : undefined,
70
+ },
71
+ },
72
+ );
73
+ } finally {
74
+ setLoading(false);
75
+ }
76
+ };
77
+
78
+ return (
79
+ <Layout>
80
+ <div className="flex flex-col space-y-2 text-center">
81
+ <h1 className="text-2xl font-semibold tracking-tight">
82
+ Set new password
83
+ </h1>
84
+ <p className="text-sm text-muted-foreground">
85
+ Choose a secure password for your account
86
+ </p>
87
+ </div>
88
+ <Form<FormData>
89
+ className="space-y-6"
90
+ onSubmit={submit as SubmitHandler<FieldValues>}
91
+ validate={validate}
92
+ >
93
+ <TextInput
94
+ source="password"
95
+ type="password"
96
+ label={translate("ra.auth.password", {
97
+ _: "New password",
98
+ })}
99
+ autoComplete="new-password"
100
+ validate={required()}
101
+ />
102
+ <TextInput
103
+ source="confirm"
104
+ type="password"
105
+ label={translate("ra.auth.confirm_password", {
106
+ _: "Confirm password",
107
+ })}
108
+ autoComplete="new-password"
109
+ validate={required()}
110
+ />
111
+ <Button type="submit" className="cursor-pointer w-full" disabled={loading}>
112
+ {loading ? "Updating..." : "Update password"}
113
+ </Button>
114
+ </Form>
115
+ </Layout>
116
+ );
117
+ };
118
+
119
+ ChangePasswordPage.path = "change-password";