realtimex-crm 0.5.7 → 0.6.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.
@@ -1,28 +1,48 @@
1
1
  import { useState } from "react";
2
- import { useResetPassword } from "ra-supabase-core";
3
2
  import { Form, required, useNotify, useTranslate } from "ra-core";
4
3
  import { Layout } from "@/components/supabase/layout";
5
4
  import type { FieldValues, SubmitHandler } from "react-hook-form";
6
5
  import { TextInput } from "@/components/admin/text-input";
7
6
  import { Button } from "@/components/ui/button";
7
+ import { OtpInput } from "@/components/supabase/otp-input";
8
+ import { supabase } from "@/components/atomic-crm/providers/supabase/supabase";
8
9
 
9
- interface FormData {
10
+ interface EmailFormData {
10
11
  email: string;
11
12
  }
12
13
 
14
+ type Step = 'email' | 'otp';
15
+
13
16
  export const ForgotPasswordPage = () => {
14
17
  const [loading, setLoading] = useState(false);
18
+ const [step, setStep] = useState<Step>('email');
19
+ const [email, setEmail] = useState('');
20
+ const [otp, setOtp] = useState('');
21
+ const [otpError, setOtpError] = useState(false);
15
22
 
16
23
  const notify = useNotify();
17
24
  const translate = useTranslate();
18
- const [, { mutateAsync: resetPassword }] = useResetPassword();
19
25
 
20
- const submit = async (values: FormData) => {
26
+ const submitEmail = async (values: EmailFormData) => {
21
27
  try {
22
28
  setLoading(true);
23
- await resetPassword({
24
- email: values.email,
29
+ // Normalize email to lowercase
30
+ const normalizedEmail = values.email.trim().toLowerCase();
31
+ setEmail(normalizedEmail);
32
+
33
+ const { error } = await supabase.auth.signInWithOtp({
34
+ email: normalizedEmail,
35
+ options: {
36
+ shouldCreateUser: false, // Only allow existing users to reset password
37
+ },
25
38
  });
39
+
40
+ if (error) {
41
+ throw error;
42
+ }
43
+
44
+ notify('A 6-digit code has been sent to your email', { type: 'success' });
45
+ setStep('otp');
26
46
  } catch (error: any) {
27
47
  notify(
28
48
  typeof error === "string"
@@ -47,38 +67,163 @@ export const ForgotPasswordPage = () => {
47
67
  }
48
68
  };
49
69
 
70
+ const verifyOtp = async (otpCode: string) => {
71
+ try {
72
+ setLoading(true);
73
+ setOtpError(false);
74
+
75
+ // Trim whitespace from OTP code
76
+ const cleanOtp = otpCode.trim();
77
+
78
+ console.log('Verifying OTP (forgot password):', { email, token: cleanOtp, type: 'email' });
79
+
80
+ const { data, error } = await supabase.auth.verifyOtp({
81
+ email: email.trim().toLowerCase(), // Normalize email
82
+ token: cleanOtp,
83
+ type: 'magiclink', // Changed from 'email' - some Supabase versions treat OTP as magiclink
84
+ });
85
+
86
+ console.log('OTP Verification result:', { data, error });
87
+
88
+ if (error) {
89
+ throw error;
90
+ }
91
+
92
+ if (!data.session) {
93
+ throw new Error('Failed to create session');
94
+ }
95
+
96
+ console.log('Session created successfully for password reset');
97
+
98
+ // User is now logged in, redirect to change password page
99
+ notify('Code verified! Please set your new password.', { type: 'success' });
100
+
101
+ // IMPORTANT: Don't call login() - user is already authenticated via Supabase
102
+ // The OTP verification already set the session
103
+ // Calling login({}) with empty params could cause session confusion
104
+
105
+ // Navigate to change password page with reload
106
+ console.log('Navigating to /change-password');
107
+ window.location.href = '#/change-password';
108
+ window.location.reload();
109
+ } catch (error: any) {
110
+ setOtpError(true);
111
+ notify(
112
+ typeof error === "string"
113
+ ? error
114
+ : typeof error === "undefined" || !error.message
115
+ ? "Invalid or expired code"
116
+ : error.message,
117
+ {
118
+ type: "warning",
119
+ messageArgs: {
120
+ _:
121
+ typeof error === "string"
122
+ ? error
123
+ : error && error.message
124
+ ? error.message
125
+ : undefined,
126
+ },
127
+ },
128
+ );
129
+ } finally {
130
+ setLoading(false);
131
+ }
132
+ };
133
+
134
+ const handleOtpComplete = (otpCode: string) => {
135
+ verifyOtp(otpCode);
136
+ };
137
+
138
+ const handleResendCode = async () => {
139
+ setOtp('');
140
+ setOtpError(false);
141
+ await submitEmail({ email });
142
+ };
143
+
50
144
  return (
51
145
  <Layout>
52
- <div className="flex flex-col space-y-2 text-center">
53
- <h1 className="text-2xl font-semibold tracking-tight">
54
- {translate("ra-supabase.reset_password.forgot_password", {
55
- _: "Forgot password?",
56
- })}
57
- </h1>
58
- <p>
59
- {translate("ra-supabase.reset_password.forgot_password_details", {
60
- _: "Enter your email to receive a reset password link.",
61
- })}
62
- </p>
63
- </div>
64
- <Form<FormData>
65
- className="space-y-8"
66
- onSubmit={submit as SubmitHandler<FieldValues>}
67
- >
68
- <TextInput
69
- source="email"
70
- label={translate("ra.auth.email", {
71
- _: "Email",
72
- })}
73
- autoComplete="email"
74
- validate={required()}
75
- />
76
- <Button type="submit" className="cursor-pointer" disabled={loading}>
77
- {translate("ra.action.reset_password", {
78
- _: "Reset password",
79
- })}
80
- </Button>
81
- </Form>
146
+ {step === 'email' ? (
147
+ <>
148
+ <div className="flex flex-col space-y-2 text-center">
149
+ <h1 className="text-2xl font-semibold tracking-tight">
150
+ {translate("ra-supabase.reset_password.forgot_password", {
151
+ _: "Forgot password?",
152
+ })}
153
+ </h1>
154
+ <p>
155
+ {translate("ra-supabase.reset_password.forgot_password_details", {
156
+ _: "Enter your email to receive a 6-digit code.",
157
+ })}
158
+ </p>
159
+ </div>
160
+ <Form<EmailFormData>
161
+ className="space-y-8"
162
+ onSubmit={submitEmail as SubmitHandler<FieldValues>}
163
+ >
164
+ <TextInput
165
+ source="email"
166
+ label={translate("ra.auth.email", {
167
+ _: "Email",
168
+ })}
169
+ autoComplete="email"
170
+ validate={required()}
171
+ />
172
+ <Button type="submit" className="cursor-pointer w-full" disabled={loading}>
173
+ {translate("ra.action.reset_password", {
174
+ _: "Send code",
175
+ })}
176
+ </Button>
177
+ </Form>
178
+ </>
179
+ ) : (
180
+ <>
181
+ <div className="flex flex-col space-y-2 text-center">
182
+ <h1 className="text-2xl font-semibold tracking-tight">
183
+ Enter verification code
184
+ </h1>
185
+ <p className="text-sm text-muted-foreground">
186
+ We've sent a 6-digit code to {email}
187
+ </p>
188
+ </div>
189
+ <div className="space-y-6">
190
+ <div className="space-y-4">
191
+ <OtpInput
192
+ length={6}
193
+ value={otp}
194
+ onChange={setOtp}
195
+ onComplete={handleOtpComplete}
196
+ disabled={loading}
197
+ error={otpError}
198
+ />
199
+ {otpError && (
200
+ <p className="text-sm text-destructive text-center">
201
+ Invalid or expired code. Please try again.
202
+ </p>
203
+ )}
204
+ </div>
205
+ <div className="flex flex-col gap-2">
206
+ <Button
207
+ type="button"
208
+ className="cursor-pointer w-full"
209
+ disabled={loading || otp.length !== 6}
210
+ onClick={() => verifyOtp(otp)}
211
+ >
212
+ {loading ? 'Verifying...' : 'Verify code'}
213
+ </Button>
214
+ <Button
215
+ type="button"
216
+ variant="outline"
217
+ className="cursor-pointer w-full"
218
+ disabled={loading}
219
+ onClick={handleResendCode}
220
+ >
221
+ Resend code
222
+ </Button>
223
+ </div>
224
+ </div>
225
+ </>
226
+ )}
82
227
  </Layout>
83
228
  );
84
229
  };
@@ -0,0 +1,116 @@
1
+ import { useRef, useState, KeyboardEvent, ClipboardEvent } from 'react';
2
+ import { Input } from '@/components/ui/input';
3
+ import { cn } from '@/lib/utils';
4
+
5
+ interface OtpInputProps {
6
+ length?: number;
7
+ value: string;
8
+ onChange: (value: string) => void;
9
+ onComplete?: (value: string) => void;
10
+ disabled?: boolean;
11
+ error?: boolean;
12
+ }
13
+
14
+ export function OtpInput({
15
+ length = 6,
16
+ value,
17
+ onChange,
18
+ onComplete,
19
+ disabled = false,
20
+ error = false,
21
+ }: OtpInputProps) {
22
+ const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
23
+ const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
24
+
25
+ const handleChange = (index: number, inputValue: string) => {
26
+ // Only allow digits
27
+ const digit = inputValue.replace(/[^0-9]/g, '');
28
+
29
+ if (digit.length === 0) {
30
+ // Handle backspace/delete
31
+ const newValue = value.split('');
32
+ newValue[index] = '';
33
+ const updatedValue = newValue.join('');
34
+ onChange(updatedValue);
35
+
36
+ // Move to previous input
37
+ if (index > 0) {
38
+ inputRefs.current[index - 1]?.focus();
39
+ }
40
+ return;
41
+ }
42
+
43
+ // Update the value at the current index
44
+ const newValue = value.split('');
45
+ newValue[index] = digit[0];
46
+ const updatedValue = newValue.join('');
47
+ onChange(updatedValue);
48
+
49
+ // Move to next input if not the last one
50
+ if (index < length - 1) {
51
+ inputRefs.current[index + 1]?.focus();
52
+ }
53
+
54
+ // Check if OTP is complete
55
+ if (updatedValue.length === length && onComplete) {
56
+ onComplete(updatedValue);
57
+ }
58
+ };
59
+
60
+ const handleKeyDown = (index: number, e: KeyboardEvent<HTMLInputElement>) => {
61
+ if (e.key === 'Backspace' && !value[index] && index > 0) {
62
+ // If current input is empty and backspace is pressed, move to previous
63
+ inputRefs.current[index - 1]?.focus();
64
+ } else if (e.key === 'ArrowLeft' && index > 0) {
65
+ inputRefs.current[index - 1]?.focus();
66
+ } else if (e.key === 'ArrowRight' && index < length - 1) {
67
+ inputRefs.current[index + 1]?.focus();
68
+ }
69
+ };
70
+
71
+ const handlePaste = (e: ClipboardEvent<HTMLInputElement>) => {
72
+ e.preventDefault();
73
+ const pastedData = e.clipboardData.getData('text/plain');
74
+ const digits = pastedData.replace(/[^0-9]/g, '').slice(0, length);
75
+
76
+ onChange(digits);
77
+
78
+ // Focus the next empty input or the last input
79
+ const nextIndex = Math.min(digits.length, length - 1);
80
+ inputRefs.current[nextIndex]?.focus();
81
+
82
+ // Check if OTP is complete
83
+ if (digits.length === length && onComplete) {
84
+ onComplete(digits);
85
+ }
86
+ };
87
+
88
+ return (
89
+ <div className="flex gap-2 justify-center">
90
+ {Array.from({ length }).map((_, index) => (
91
+ <Input
92
+ key={index}
93
+ ref={(el) => {
94
+ inputRefs.current[index] = el;
95
+ }}
96
+ type="text"
97
+ inputMode="numeric"
98
+ maxLength={1}
99
+ value={value[index] || ''}
100
+ onChange={(e) => handleChange(index, e.target.value)}
101
+ onKeyDown={(e) => handleKeyDown(index, e)}
102
+ onPaste={handlePaste}
103
+ onFocus={() => setFocusedIndex(index)}
104
+ onBlur={() => setFocusedIndex(null)}
105
+ disabled={disabled}
106
+ className={cn(
107
+ 'w-12 h-12 text-center text-lg font-semibold',
108
+ error && 'border-destructive focus-visible:ring-destructive',
109
+ focusedIndex === index && 'ring-2 ring-ring'
110
+ )}
111
+ aria-label={`Digit ${index + 1}`}
112
+ />
113
+ ))}
114
+ </div>
115
+ );
116
+ }
@@ -0,0 +1,254 @@
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 { OtpInput } from "@/components/supabase/otp-input";
8
+ import { supabase } from "@/components/atomic-crm/providers/supabase/supabase";
9
+
10
+ interface EmailFormData {
11
+ email: string;
12
+ }
13
+
14
+ type Step = 'email' | 'otp';
15
+
16
+ export const OtpLoginPage = () => {
17
+ const [loading, setLoading] = useState(false);
18
+ const [step, setStep] = useState<Step>('email');
19
+ const [email, setEmail] = useState('');
20
+ const [otp, setOtp] = useState('');
21
+ const [otpError, setOtpError] = useState(false);
22
+
23
+ const notify = useNotify();
24
+ const translate = useTranslate();
25
+
26
+ const submitEmail = async (values: EmailFormData) => {
27
+ try {
28
+ setLoading(true);
29
+ // Normalize email to lowercase
30
+ const normalizedEmail = values.email.trim().toLowerCase();
31
+ setEmail(normalizedEmail);
32
+
33
+ const { error } = await supabase.auth.signInWithOtp({
34
+ email: normalizedEmail,
35
+ options: {
36
+ shouldCreateUser: false, // Only allow existing users
37
+ },
38
+ });
39
+
40
+ if (error) {
41
+ throw error;
42
+ }
43
+
44
+ notify('A 6-digit code has been sent to your email', { type: 'success' });
45
+ setStep('otp');
46
+ } catch (error: any) {
47
+ notify(
48
+ typeof error === "string"
49
+ ? error
50
+ : typeof error === "undefined" || !error.message
51
+ ? "ra.auth.sign_in_error"
52
+ : error.message,
53
+ {
54
+ type: "warning",
55
+ messageArgs: {
56
+ _:
57
+ typeof error === "string"
58
+ ? error
59
+ : error && error.message
60
+ ? error.message
61
+ : undefined,
62
+ },
63
+ },
64
+ );
65
+ } finally {
66
+ setLoading(false);
67
+ }
68
+ };
69
+
70
+ const verifyOtp = async (otpCode: string) => {
71
+ try {
72
+ setLoading(true);
73
+ setOtpError(false);
74
+
75
+ // Trim whitespace from OTP code
76
+ const cleanOtp = otpCode.trim();
77
+
78
+ console.log('Verifying OTP:', { email, token: cleanOtp, type: 'email' });
79
+
80
+ const { data, error } = await supabase.auth.verifyOtp({
81
+ email: email.trim().toLowerCase(), // Normalize email
82
+ token: cleanOtp,
83
+ type: 'magiclink', // Changed from 'email' - some Supabase versions treat OTP as magiclink
84
+ });
85
+
86
+ console.log('OTP Verification result:', { data, error });
87
+
88
+ if (error) {
89
+ throw error;
90
+ }
91
+
92
+ if (!data.session) {
93
+ throw new Error('Failed to create session');
94
+ }
95
+
96
+ console.log('Session created successfully, user:', data.user);
97
+
98
+ // Check if user exists in sales table (access control)
99
+ console.log('Checking sales table for user_id:', data.user.id);
100
+ const { data: saleData, error: saleError } = await supabase
101
+ .from('sales')
102
+ .select('id, email_confirmed_at')
103
+ .eq('user_id', data.user.id)
104
+ .single();
105
+
106
+ console.log('Sales table query result:', { saleData, saleError });
107
+
108
+ if (saleError || !saleData) {
109
+ // User authenticated but not in sales table - deny access
110
+ console.error('User not found in sales table or query error');
111
+ await supabase.auth.signOut();
112
+ throw new Error('You do not have access to this application. Please contact your administrator.');
113
+ }
114
+
115
+ // User is logged in and authorized
116
+ console.log('User authorized, OTP verification complete');
117
+ notify('Login successful!', { type: 'success' });
118
+
119
+ // IMPORTANT: Don't call login() - user is already authenticated via Supabase
120
+ // The OTP verification already set the session
121
+ // Calling login({}) with empty params could cause session confusion
122
+
123
+ // Force reload to ensure clean state
124
+ console.log('Reloading to establish session...');
125
+
126
+ // Check if this is their first login (email not confirmed yet)
127
+ if (!saleData.email_confirmed_at) {
128
+ // Navigate to change password
129
+ window.location.href = '#/change-password';
130
+ window.location.reload();
131
+ } else {
132
+ // Navigate to dashboard
133
+ window.location.href = '#/';
134
+ window.location.reload();
135
+ }
136
+ } catch (error: any) {
137
+ setOtpError(true);
138
+ notify(
139
+ typeof error === "string"
140
+ ? error
141
+ : typeof error === "undefined" || !error.message
142
+ ? "Invalid or expired code"
143
+ : error.message,
144
+ {
145
+ type: "warning",
146
+ messageArgs: {
147
+ _:
148
+ typeof error === "string"
149
+ ? error
150
+ : error && error.message
151
+ ? error.message
152
+ : undefined,
153
+ },
154
+ },
155
+ );
156
+ } finally {
157
+ setLoading(false);
158
+ }
159
+ };
160
+
161
+ const handleOtpComplete = (otpCode: string) => {
162
+ verifyOtp(otpCode);
163
+ };
164
+
165
+ const handleResendCode = async () => {
166
+ setOtp('');
167
+ setOtpError(false);
168
+ await submitEmail({ email });
169
+ };
170
+
171
+ return (
172
+ <Layout>
173
+ {step === 'email' ? (
174
+ <>
175
+ <div className="flex flex-col space-y-2 text-center">
176
+ <h1 className="text-2xl font-semibold tracking-tight">
177
+ Login with code
178
+ </h1>
179
+ <p className="text-sm text-muted-foreground">
180
+ Enter your email to receive a 6-digit login code
181
+ </p>
182
+ </div>
183
+ <Form<EmailFormData>
184
+ className="space-y-8"
185
+ onSubmit={submitEmail as SubmitHandler<FieldValues>}
186
+ >
187
+ <TextInput
188
+ source="email"
189
+ label={translate("ra.auth.email", {
190
+ _: "Email",
191
+ })}
192
+ autoComplete="email"
193
+ validate={required()}
194
+ />
195
+ <Button type="submit" className="cursor-pointer w-full" disabled={loading}>
196
+ {translate("ra.action.send_code", {
197
+ _: "Send code",
198
+ })}
199
+ </Button>
200
+ </Form>
201
+ </>
202
+ ) : (
203
+ <>
204
+ <div className="flex flex-col space-y-2 text-center">
205
+ <h1 className="text-2xl font-semibold tracking-tight">
206
+ Enter verification code
207
+ </h1>
208
+ <p className="text-sm text-muted-foreground">
209
+ We've sent a 6-digit code to {email}
210
+ </p>
211
+ </div>
212
+ <div className="space-y-6">
213
+ <div className="space-y-4">
214
+ <OtpInput
215
+ length={6}
216
+ value={otp}
217
+ onChange={setOtp}
218
+ onComplete={handleOtpComplete}
219
+ disabled={loading}
220
+ error={otpError}
221
+ />
222
+ {otpError && (
223
+ <p className="text-sm text-destructive text-center">
224
+ Invalid or expired code. Please try again.
225
+ </p>
226
+ )}
227
+ </div>
228
+ <div className="flex flex-col gap-2">
229
+ <Button
230
+ type="button"
231
+ className="cursor-pointer w-full"
232
+ disabled={loading || otp.length !== 6}
233
+ onClick={() => verifyOtp(otp)}
234
+ >
235
+ {loading ? 'Verifying...' : 'Verify code'}
236
+ </Button>
237
+ <Button
238
+ type="button"
239
+ variant="outline"
240
+ className="cursor-pointer w-full"
241
+ disabled={loading}
242
+ onClick={handleResendCode}
243
+ >
244
+ Resend code
245
+ </Button>
246
+ </div>
247
+ </div>
248
+ </>
249
+ )}
250
+ </Layout>
251
+ );
252
+ };
253
+
254
+ OtpLoginPage.path = "otp-login";