diff --git a/app/components/welcome/contents/EmailLoginContent.tsx b/app/components/welcome/contents/EmailLoginContent.tsx index 37de3bdb..033cd30a 100644 --- a/app/components/welcome/contents/EmailLoginContent.tsx +++ b/app/components/welcome/contents/EmailLoginContent.tsx @@ -3,6 +3,7 @@ import { Button } from '@/app/components/ui/button' import { AppOrbitImage } from '@/app/components/ui/app-orbit-image' import { isValidEmail, isStrongPassword } from '@/app/utils/utils' import { useAuth } from '@/app/components/auth/useAuth' +import ResetPassword from './ResetPassword' type Props = { initialEmail?: string @@ -14,6 +15,7 @@ export default function EmailLoginContent({ initialEmail = '', onBack, }: Props) { + const [showResetPassword, setShowResetPassword] = useState(false) const [email, setEmail] = useState(initialEmail) const [password, setPassword] = useState('') @@ -43,6 +45,15 @@ export default function EmailLoginContent({ } } + if (showResetPassword) { + return ( + setShowResetPassword(false)} + /> + ) + } + return (
{/* Left: form */} @@ -115,9 +126,12 @@ export default function EmailLoginContent({ - +
diff --git a/app/components/welcome/contents/ResetPassword.tsx b/app/components/welcome/contents/ResetPassword.tsx new file mode 100644 index 00000000..0cf3f15d --- /dev/null +++ b/app/components/welcome/contents/ResetPassword.tsx @@ -0,0 +1,226 @@ +import { useMemo, useState } from 'react' +import { Button } from '@/app/components/ui/button' +import { AppOrbitImage } from '../../ui/app-orbit-image' +import { STORE_KEYS } from '../../../../lib/constants/store-keys' +import { isValidEmail } from '@/app/utils/utils' + +type Props = { + email?: string + onBack: () => void +} + +export default function ResetPassword({ email, onBack }: Props) { + const storedUser = window.electron?.store?.get(STORE_KEYS.AUTH)?.user + const initialEmail = email || storedUser?.email || '' + + const [editableEmail, setEditableEmail] = useState(initialEmail) + const [isEditingEmail, setIsEditingEmail] = useState(!initialEmail) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const [emailSent, setEmailSent] = useState(false) + const [seconds, setSeconds] = useState(0) + const [isResending, setIsResending] = useState(false) + + const emailOk = useMemo(() => isValidEmail(editableEmail), [editableEmail]) + + const handleContinue = async () => { + if (!editableEmail || !emailOk) { + setError('Please enter a valid email address') + return + } + + try { + setIsLoading(true) + setError(null) + + const res = await window.api.invoke('auth0-reset-password', { + email: editableEmail, + }) + + if (res?.success) { + setEmailSent(true) + setSeconds(30) + // Start countdown + const id = setInterval(() => { + setSeconds(s => { + if (s <= 1) { + clearInterval(id) + return 0 + } + return s - 1 + }) + }, 1000) + } else { + setError(res?.error || 'Failed to send reset email') + } + } catch (e: any) { + setError(e?.message || 'An error occurred') + } finally { + setIsLoading(false) + } + } + + const handleResend = async () => { + if (seconds > 0 || isResending) return + + try { + setIsResending(true) + setError(null) + + const res = await window.api.invoke('auth0-reset-password', { + email: editableEmail, + }) + + if (res?.success) { + setSeconds(30) + const id = setInterval(() => { + setSeconds(s => { + if (s <= 1) { + clearInterval(id) + return 0 + } + return s - 1 + }) + }, 1000) + } else { + setError(res?.error || 'Failed to resend reset email') + } + } catch (e: any) { + setError(e?.message || 'An error occurred') + } finally { + setIsResending(false) + } + } + + const handleOpenEmailApp = () => { + window.api.invoke('web-open-url', 'mailto:') + } + + // Check Your Inbox view (after email sent) + if (emailSent) { + return ( +
+ {/* Left content */} +
+ + +
+

+ Check Your Inbox +

+

+ We've sent a reset link to {editableEmail}. +

+

+ Follow the instructions in the email to set a new password. +

+
+ + + + + + {error && ( +

{error}

+ )} +
+ + {/* Right illustration */} +
+ +
+
+ ) + } + + // Initial Reset Password view + return ( +
+ {/* Left content */} +
+ + +
+

+ Reset Password +

+ {isEditingEmail ? ( +
+ + setEditableEmail(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter' && emailOk) { + e.preventDefault() + handleContinue() + } + }} + className="mt-2 h-10 w-full rounded-md border border-border bg-background px-3 text-foreground placeholder:text-muted-foreground" + /> +
+ ) : ( +

+ We'll send a reset link to {editableEmail}.{' '} + +

+ )} +
+ + + + {error && ( +

{error}

+ )} +
+ + {/* Right illustration */} +
+ +
+
+ ) +} diff --git a/app/components/welcome/contents/SignInContent.tsx b/app/components/welcome/contents/SignInContent.tsx index c3941fb5..f5df6912 100644 --- a/app/components/welcome/contents/SignInContent.tsx +++ b/app/components/welcome/contents/SignInContent.tsx @@ -19,6 +19,7 @@ import { useDictionaryStore } from '@/app/store/useDictionaryStore' import { AppOrbitImage } from '@/app/components/ui/app-orbit-image' import { STORE_KEYS } from '../../../../lib/constants/store-keys' import { isValidEmail, isStrongPassword } from '@/app/utils/utils' +import ResetPassword from './ResetPassword' // Auth provider configuration const AUTH_PROVIDERS = { @@ -120,6 +121,7 @@ export default function SignInContent() { const [password, setPassword] = useState('') const [isLoggingIn, setIsLoggingIn] = useState(false) const [errorMessage, setErrorMessage] = useState(null) + const [showResetPassword, setShowResetPassword] = useState(false) const { user, @@ -412,10 +414,14 @@ export default function SignInContent() { return renderSingleProviderOption(userProvider) } + if (showResetPassword) { + return setShowResetPassword(false)} /> + } + return (
{/* Left side - Sign in form */} -
+
{/* Logo */}
@@ -454,11 +460,8 @@ export default function SignInContent() { )} {/* Link to create new account */} -
+

- {userProvider - ? 'Sign in with a different account?' - : 'Need to create an account?'}{' '}

+ {(!userProvider || userProvider === 'email') && ( +

+ +

+ )}
{/* Right side - Placeholder for image */} -
+
diff --git a/lib/window/ipcEvents.ts b/lib/window/ipcEvents.ts index 9e63d809..502b1ab1 100644 --- a/lib/window/ipcEvents.ts +++ b/lib/window/ipcEvents.ts @@ -421,6 +421,15 @@ export function registerIPC() { }) }) + // Send password reset email via server proxy + handleIPC('auth0-reset-password', async (_e, { email }) => { + if (!email) return { success: false, error: 'Missing email' } + return itoHttpClient.post('/auth0/reset-password', { + email, + connection: Auth0Connections.database, + }) + }) + // Check if email exists for db signup and whether it's verified (via server proxy) handleIPC('auth0-check-email', async (_e, { email }) => { if (!email) return { success: false, error: 'Missing email' } diff --git a/scripts/clean-app-data.js b/scripts/clean-app-data.js index dafca0f0..20831112 100644 --- a/scripts/clean-app-data.js +++ b/scripts/clean-app-data.js @@ -5,22 +5,27 @@ const fs = require('fs') const path = require('path') const platform = os.platform() -let appDataPath +const appNames = ['Ito-dev', 'Ito-local', 'Ito-prod', 'Ito'] -if (platform === 'darwin') { - appDataPath = path.join(os.homedir(), 'Library', 'Application Support', 'Ito') -} else if (platform === 'win32') { - appDataPath = path.join( - process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), - 'Ito', - ) -} else { - appDataPath = path.join(os.homedir(), '.config', 'ito') +function getAppDataPath(appName) { + if (platform === 'darwin') { + return path.join(os.homedir(), 'Library', 'Application Support', appName) + } else if (platform === 'win32') { + return path.join( + process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), + appName, + ) + } else { + return path.join(os.homedir(), '.config', appName.toLowerCase()) + } } -if (fs.existsSync(appDataPath)) { - fs.rmSync(appDataPath, { recursive: true, force: true }) - console.log(`✓ Removed app data from: ${appDataPath}`) -} else { - console.log(`ℹ No app data found at: ${appDataPath}`) +for (const appName of appNames) { + const appDataPath = getAppDataPath(appName) + if (fs.existsSync(appDataPath)) { + fs.rmSync(appDataPath, { recursive: true, force: true }) + console.log(`✓ Removed app data from: ${appDataPath}`) + } else { + console.log(`ℹ No app data found at: ${appDataPath}`) + } } diff --git a/server/src/services/auth0.ts b/server/src/services/auth0.ts index 5eb3d5e9..3300e23a 100644 --- a/server/src/services/auth0.ts +++ b/server/src/services/auth0.ts @@ -5,14 +5,25 @@ type SendVerificationBody = { clientId?: string } +type ResetPasswordBody = { + email?: string + connection?: string +} + export const registerAuth0Routes = async (fastify: FastifyInstance) => { const AUTH0_DOMAIN = process.env.AUTH0_DOMAIN + const AUTH0_CLIENT_ID = process.env.AUTH0_CLIENT_ID const AUTH0_MGMT_CLIENT_ID = process.env.AUTH0_MGMT_CLIENT_ID const AUTH0_MGMT_CLIENT_SECRET = process.env.AUTH0_MGMT_CLIENT_SECRET if (!AUTH0_DOMAIN) { fastify.log.error('AUTH0_DOMAIN is not set') } + if (!AUTH0_CLIENT_ID) { + fastify.log.warn( + 'AUTH0_CLIENT_ID is not set; reset-password route will fail', + ) + } if (!AUTH0_MGMT_CLIENT_ID || !AUTH0_MGMT_CLIENT_SECRET) { fastify.log.warn( 'Auth0 management client credentials are not fully set; management routes will fail', @@ -159,4 +170,50 @@ export const registerAuth0Routes = async (fastify: FastifyInstance) => { .send({ success: false, error: error?.message || 'Network error' }) } }) + + fastify.post('/auth0/reset-password', async (request, reply) => { + const body = (request.body as ResetPasswordBody) || {} + const { email, connection = 'Username-Password-Authentication' } = body + + if (!email) { + reply.status(400).send({ success: false, error: 'Missing email' }) + return + } + if (!AUTH0_DOMAIN || !AUTH0_CLIENT_ID) { + reply.status(500).send({ success: false, error: 'Auth0 not configured' }) + return + } + + try { + const url = `https://${AUTH0_DOMAIN}/dbconnections/change_password` + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + client_id: AUTH0_CLIENT_ID, + email, + connection, + }), + }) + + if (res.ok) { + reply.send({ success: true }) + return + } + + let data: any + try { + data = await res.json() + } catch { + data = { error: await res.text() } + } + const message = + data?.error_description || data?.error || `Reset failed (${res.status})` + reply.status(res.status).send({ success: false, error: message }) + } catch (error: any) { + reply + .status(500) + .send({ success: false, error: error?.message || 'Network error' }) + } + }) }