Seth commited on
Commit ·
3e30e4e
1
Parent(s): bd5e962
update
Browse files- backend/app/main.py +3 -2
- frontend/src/App.jsx +17 -11
- frontend/src/components/auth/RequireAuth.jsx +21 -0
- frontend/src/components/layout/GoogleAuthBar.jsx +49 -137
- frontend/src/context/AuthContext.jsx +62 -0
- frontend/src/pages/Settings.jsx +1 -4
- frontend/src/pages/SignIn.jsx +127 -0
backend/app/main.py
CHANGED
|
@@ -2316,8 +2316,9 @@ async def unipile_webhook(request: Request, db: Session = Depends(get_db)):
|
|
| 2316 |
|
| 2317 |
|
| 2318 |
@app.get("/api/unipile/webhook-url-hint")
|
| 2319 |
-
async def unipile_webhook_url_hint(request: Request):
|
| 2320 |
"""Public URL your UniPile webhooks should POST to (same host as this API)."""
|
|
|
|
| 2321 |
root = _public_origin_from_request(request)
|
| 2322 |
post_url = f"{root}/api/webhooks/unipile"
|
| 2323 |
return {
|
|
@@ -4779,7 +4780,7 @@ async def download_sequences(file_id: str = Query(...), t: TenantContext = Depen
|
|
| 4779 |
|
| 4780 |
|
| 4781 |
@app.get("/api/smartlead-campaigns")
|
| 4782 |
-
async def get_smartlead_campaigns():
|
| 4783 |
"""Get list of campaigns from Smartlead"""
|
| 4784 |
try:
|
| 4785 |
client = SmartleadClient()
|
|
|
|
| 2316 |
|
| 2317 |
|
| 2318 |
@app.get("/api/unipile/webhook-url-hint")
|
| 2319 |
+
async def unipile_webhook_url_hint(request: Request, t: TenantContext = Depends(get_tenant_context)):
|
| 2320 |
"""Public URL your UniPile webhooks should POST to (same host as this API)."""
|
| 2321 |
+
_ = t
|
| 2322 |
root = _public_origin_from_request(request)
|
| 2323 |
post_url = f"{root}/api/webhooks/unipile"
|
| 2324 |
return {
|
|
|
|
| 4780 |
|
| 4781 |
|
| 4782 |
@app.get("/api/smartlead-campaigns")
|
| 4783 |
+
async def get_smartlead_campaigns(t: TenantContext = Depends(get_tenant_context)):
|
| 4784 |
"""Get list of campaigns from Smartlead"""
|
| 4785 |
try:
|
| 4786 |
client = SmartleadClient()
|
frontend/src/App.jsx
CHANGED
|
@@ -1,5 +1,7 @@
|
|
| 1 |
import React from "react";
|
| 2 |
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
|
|
|
|
|
|
| 3 |
import { GeneratorWorkflowProvider } from "./context/GeneratorWorkflowContext";
|
| 4 |
import EmailSequenceGenerator from "./pages/EmailSequenceGenerator";
|
| 5 |
import Contacts from "./pages/Contacts";
|
|
@@ -12,17 +14,21 @@ import "./index.css";
|
|
| 12 |
export default function App() {
|
| 13 |
return (
|
| 14 |
<BrowserRouter>
|
| 15 |
-
<
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
</BrowserRouter>
|
| 27 |
);
|
| 28 |
}
|
|
|
|
| 1 |
import React from "react";
|
| 2 |
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
| 3 |
+
import { AuthProvider } from "./context/AuthContext";
|
| 4 |
+
import RequireAuth from "./components/auth/RequireAuth";
|
| 5 |
import { GeneratorWorkflowProvider } from "./context/GeneratorWorkflowContext";
|
| 6 |
import EmailSequenceGenerator from "./pages/EmailSequenceGenerator";
|
| 7 |
import Contacts from "./pages/Contacts";
|
|
|
|
| 14 |
export default function App() {
|
| 15 |
return (
|
| 16 |
<BrowserRouter>
|
| 17 |
+
<AuthProvider>
|
| 18 |
+
<RequireAuth>
|
| 19 |
+
<GeneratorWorkflowProvider>
|
| 20 |
+
<Routes>
|
| 21 |
+
<Route path="/" element={<EmailSequenceGenerator />} />
|
| 22 |
+
<Route path="/contacts" element={<Contacts />} />
|
| 23 |
+
<Route path="/leads" element={<Leads />} />
|
| 24 |
+
<Route path="/deals" element={<Deals />} />
|
| 25 |
+
<Route path="/dashboard" element={<SalesDashboard />} />
|
| 26 |
+
<Route path="/settings" element={<Settings />} />
|
| 27 |
+
<Route path="/history" element={<Navigate to="/leads" replace />} />
|
| 28 |
+
</Routes>
|
| 29 |
+
</GeneratorWorkflowProvider>
|
| 30 |
+
</RequireAuth>
|
| 31 |
+
</AuthProvider>
|
| 32 |
</BrowserRouter>
|
| 33 |
);
|
| 34 |
}
|
frontend/src/components/auth/RequireAuth.jsx
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { useAuth } from '@/context/AuthContext';
|
| 3 |
+
import SignIn from '@/pages/SignIn';
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Blocks the app until the user has a valid Google session.
|
| 7 |
+
* API routes remain protected server-side; this prevents browsing the UI while signed out.
|
| 8 |
+
*/
|
| 9 |
+
export default function RequireAuth({ children }) {
|
| 10 |
+
const { phase, signedIn } = useAuth();
|
| 11 |
+
|
| 12 |
+
if (phase === 'loading') {
|
| 13 |
+
return <SignIn />;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
if (!signedIn) {
|
| 17 |
+
return <SignIn />;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
return children;
|
| 21 |
+
}
|
frontend/src/components/layout/GoogleAuthBar.jsx
CHANGED
|
@@ -1,74 +1,27 @@
|
|
| 1 |
-
import React
|
| 2 |
-
import { Link, useLocation
|
| 3 |
import { LogOut, Settings } from 'lucide-react';
|
| 4 |
import { Button } from '@/components/ui/button';
|
| 5 |
import { cn } from '@/lib/utils';
|
| 6 |
import { apiFetch } from '@/lib/api';
|
|
|
|
| 7 |
|
| 8 |
/**
|
| 9 |
-
*
|
| 10 |
*/
|
| 11 |
export default function GoogleAuthBar() {
|
| 12 |
const location = useLocation();
|
| 13 |
-
const
|
| 14 |
-
const [phase, setPhase] = useState('loading');
|
| 15 |
-
const [googleOn, setGoogleOn] = useState(false);
|
| 16 |
-
const [user, setUser] = useState(null);
|
| 17 |
-
const refresh = useCallback(async () => {
|
| 18 |
-
try {
|
| 19 |
-
const [st, me] = await Promise.all([
|
| 20 |
-
apiFetch('/api/auth/status').then((r) => r.json()),
|
| 21 |
-
apiFetch('/api/auth/me').then((r) => (r.ok ? r.json() : null)),
|
| 22 |
-
]);
|
| 23 |
-
setGoogleOn(!!st.googleConfigured);
|
| 24 |
-
setUser(me);
|
| 25 |
-
setPhase('ready');
|
| 26 |
-
if (typeof window !== 'undefined') {
|
| 27 |
-
window.dispatchEvent(new CustomEvent('emailout-auth-changed'));
|
| 28 |
-
}
|
| 29 |
-
} catch {
|
| 30 |
-
setPhase('error');
|
| 31 |
-
}
|
| 32 |
-
}, []);
|
| 33 |
-
|
| 34 |
-
useEffect(() => {
|
| 35 |
-
refresh();
|
| 36 |
-
}, [refresh]);
|
| 37 |
-
|
| 38 |
-
useEffect(() => {
|
| 39 |
-
const err = searchParams.get('auth_error');
|
| 40 |
-
if (!err) return;
|
| 41 |
-
if (err === 'access_denied') {
|
| 42 |
-
console.info('Google sign-in was cancelled.');
|
| 43 |
-
} else if (err === 'invite_email_mismatch') {
|
| 44 |
-
console.warn(
|
| 45 |
-
'Invitation email does not match your Google account. Sign in with the invited address.'
|
| 46 |
-
);
|
| 47 |
-
} else {
|
| 48 |
-
console.warn('Google sign-in error:', err);
|
| 49 |
-
}
|
| 50 |
-
const next = new URLSearchParams(searchParams);
|
| 51 |
-
next.delete('auth_error');
|
| 52 |
-
setSearchParams(next, { replace: true });
|
| 53 |
-
}, [searchParams, setSearchParams]);
|
| 54 |
|
| 55 |
const logout = async () => {
|
| 56 |
try {
|
| 57 |
await apiFetch('/api/auth/logout', { method: 'POST' });
|
| 58 |
-
|
| 59 |
-
if (typeof window !== 'undefined') {
|
| 60 |
-
window.dispatchEvent(new CustomEvent('emailout-auth-changed'));
|
| 61 |
-
}
|
| 62 |
} catch (e) {
|
| 63 |
console.error(e);
|
| 64 |
}
|
| 65 |
};
|
| 66 |
|
| 67 |
-
const inviteParam = searchParams.get('invite');
|
| 68 |
-
const googleHref = inviteParam
|
| 69 |
-
? `/api/auth/google?invite=${encodeURIComponent(inviteParam)}`
|
| 70 |
-
: '/api/auth/google';
|
| 71 |
-
|
| 72 |
if (phase === 'loading' || (phase === 'ready' && !googleOn)) {
|
| 73 |
if (phase === 'loading') {
|
| 74 |
return (
|
|
@@ -81,94 +34,53 @@ export default function GoogleAuthBar() {
|
|
| 81 |
return null;
|
| 82 |
}
|
| 83 |
|
| 84 |
-
if (phase === 'error') {
|
| 85 |
return null;
|
| 86 |
}
|
| 87 |
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
location.pathname === '/settings' || location.pathname.startsWith('/settings/');
|
| 91 |
-
return (
|
| 92 |
-
<div className="flex max-w-[min(100vw-6rem,28rem)] flex-wrap items-center justify-end gap-2">
|
| 93 |
-
<Button
|
| 94 |
-
asChild
|
| 95 |
-
variant="ghost"
|
| 96 |
-
size="icon"
|
| 97 |
-
className={cn(
|
| 98 |
-
'h-9 w-9 shrink-0 text-slate-600 hover:text-violet-700 hover:bg-violet-50',
|
| 99 |
-
settingsActive && 'text-violet-700 bg-violet-50'
|
| 100 |
-
)}
|
| 101 |
-
title="Settings"
|
| 102 |
-
aria-current={settingsActive ? 'page' : undefined}
|
| 103 |
-
>
|
| 104 |
-
<Link to="/settings">
|
| 105 |
-
<Settings className="h-5 w-5" aria-hidden />
|
| 106 |
-
<span className="sr-only">Settings</span>
|
| 107 |
-
</Link>
|
| 108 |
-
</Button>
|
| 109 |
-
{user.picture ? (
|
| 110 |
-
<img
|
| 111 |
-
src={user.picture}
|
| 112 |
-
alt=""
|
| 113 |
-
className="h-8 w-8 shrink-0 rounded-full border border-slate-200 object-cover"
|
| 114 |
-
referrerPolicy="no-referrer"
|
| 115 |
-
/>
|
| 116 |
-
) : null}
|
| 117 |
-
<span className="hidden min-w-0 truncate text-sm text-slate-600 lg:inline">
|
| 118 |
-
{user.name || user.email || 'Signed in'}
|
| 119 |
-
</span>
|
| 120 |
-
<Button
|
| 121 |
-
type="button"
|
| 122 |
-
variant="outline"
|
| 123 |
-
size="sm"
|
| 124 |
-
className="shrink-0 gap-1"
|
| 125 |
-
onClick={logout}
|
| 126 |
-
title="Sign out"
|
| 127 |
-
>
|
| 128 |
-
<LogOut className="h-3.5 w-3.5" />
|
| 129 |
-
<span className="hidden sm:inline">Sign out</span>
|
| 130 |
-
</Button>
|
| 131 |
-
</div>
|
| 132 |
-
);
|
| 133 |
-
}
|
| 134 |
-
|
| 135 |
-
return (
|
| 136 |
-
<Button
|
| 137 |
-
asChild
|
| 138 |
-
variant="outline"
|
| 139 |
-
size="sm"
|
| 140 |
-
className={cn(
|
| 141 |
-
'shrink-0 gap-2 border-slate-200 bg-white text-slate-800 shadow-sm',
|
| 142 |
-
'hover:bg-slate-50'
|
| 143 |
-
)}
|
| 144 |
-
>
|
| 145 |
-
<a href={googleHref} className="inline-flex items-center gap-2">
|
| 146 |
-
<GoogleMark className="h-4 w-4 shrink-0" />
|
| 147 |
-
<span className="whitespace-nowrap">Sign in with Google</span>
|
| 148 |
-
</a>
|
| 149 |
-
</Button>
|
| 150 |
-
);
|
| 151 |
-
}
|
| 152 |
|
| 153 |
-
function GoogleMark({ className }) {
|
| 154 |
return (
|
| 155 |
-
<
|
| 156 |
-
<
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
/>
|
| 172 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
);
|
| 174 |
}
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { Link, useLocation } from 'react-router-dom';
|
| 3 |
import { LogOut, Settings } from 'lucide-react';
|
| 4 |
import { Button } from '@/components/ui/button';
|
| 5 |
import { cn } from '@/lib/utils';
|
| 6 |
import { apiFetch } from '@/lib/api';
|
| 7 |
+
import { useAuth } from '@/context/AuthContext';
|
| 8 |
|
| 9 |
/**
|
| 10 |
+
* Signed-in user menu (sign-out, settings). Sign-in is handled by RequireAuth + SignIn page.
|
| 11 |
*/
|
| 12 |
export default function GoogleAuthBar() {
|
| 13 |
const location = useLocation();
|
| 14 |
+
const { phase, googleOn, user, refresh } = useAuth();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
const logout = async () => {
|
| 17 |
try {
|
| 18 |
await apiFetch('/api/auth/logout', { method: 'POST' });
|
| 19 |
+
await refresh();
|
|
|
|
|
|
|
|
|
|
| 20 |
} catch (e) {
|
| 21 |
console.error(e);
|
| 22 |
}
|
| 23 |
};
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
if (phase === 'loading' || (phase === 'ready' && !googleOn)) {
|
| 26 |
if (phase === 'loading') {
|
| 27 |
return (
|
|
|
|
| 34 |
return null;
|
| 35 |
}
|
| 36 |
|
| 37 |
+
if (phase === 'error' || !user) {
|
| 38 |
return null;
|
| 39 |
}
|
| 40 |
|
| 41 |
+
const settingsActive =
|
| 42 |
+
location.pathname === '/settings' || location.pathname.startsWith('/settings/');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
|
|
|
| 44 |
return (
|
| 45 |
+
<div className="flex max-w-[min(100vw-6rem,28rem)] flex-wrap items-center justify-end gap-2">
|
| 46 |
+
<Button
|
| 47 |
+
asChild
|
| 48 |
+
variant="ghost"
|
| 49 |
+
size="icon"
|
| 50 |
+
className={cn(
|
| 51 |
+
'h-9 w-9 shrink-0 text-slate-600 hover:text-violet-700 hover:bg-violet-50',
|
| 52 |
+
settingsActive && 'text-violet-700 bg-violet-50'
|
| 53 |
+
)}
|
| 54 |
+
title="Settings"
|
| 55 |
+
aria-current={settingsActive ? 'page' : undefined}
|
| 56 |
+
>
|
| 57 |
+
<Link to="/settings">
|
| 58 |
+
<Settings className="h-5 w-5" aria-hidden />
|
| 59 |
+
<span className="sr-only">Settings</span>
|
| 60 |
+
</Link>
|
| 61 |
+
</Button>
|
| 62 |
+
{user.picture ? (
|
| 63 |
+
<img
|
| 64 |
+
src={user.picture}
|
| 65 |
+
alt=""
|
| 66 |
+
className="h-8 w-8 shrink-0 rounded-full border border-slate-200 object-cover"
|
| 67 |
+
referrerPolicy="no-referrer"
|
| 68 |
+
/>
|
| 69 |
+
) : null}
|
| 70 |
+
<span className="hidden min-w-0 truncate text-sm text-slate-600 lg:inline">
|
| 71 |
+
{user.name || user.email || 'Signed in'}
|
| 72 |
+
</span>
|
| 73 |
+
<Button
|
| 74 |
+
type="button"
|
| 75 |
+
variant="outline"
|
| 76 |
+
size="sm"
|
| 77 |
+
className="shrink-0 gap-1"
|
| 78 |
+
onClick={logout}
|
| 79 |
+
title="Sign out"
|
| 80 |
+
>
|
| 81 |
+
<LogOut className="h-3.5 w-3.5" />
|
| 82 |
+
<span className="hidden sm:inline">Sign out</span>
|
| 83 |
+
</Button>
|
| 84 |
+
</div>
|
| 85 |
);
|
| 86 |
}
|
frontend/src/context/AuthContext.jsx
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, {
|
| 2 |
+
createContext,
|
| 3 |
+
useCallback,
|
| 4 |
+
useContext,
|
| 5 |
+
useEffect,
|
| 6 |
+
useMemo,
|
| 7 |
+
useState,
|
| 8 |
+
} from 'react';
|
| 9 |
+
import { apiFetch } from '@/lib/api';
|
| 10 |
+
|
| 11 |
+
const AuthContext = createContext(null);
|
| 12 |
+
|
| 13 |
+
export function AuthProvider({ children }) {
|
| 14 |
+
const [phase, setPhase] = useState('loading');
|
| 15 |
+
const [googleOn, setGoogleOn] = useState(false);
|
| 16 |
+
const [user, setUser] = useState(null);
|
| 17 |
+
|
| 18 |
+
const refresh = useCallback(async () => {
|
| 19 |
+
try {
|
| 20 |
+
const [st, meRes] = await Promise.all([
|
| 21 |
+
apiFetch('/api/auth/status').then((r) => r.json()),
|
| 22 |
+
apiFetch('/api/auth/me'),
|
| 23 |
+
]);
|
| 24 |
+
setGoogleOn(!!st.googleConfigured);
|
| 25 |
+
setUser(meRes.ok ? await meRes.json() : null);
|
| 26 |
+
setPhase('ready');
|
| 27 |
+
} catch {
|
| 28 |
+
setPhase('error');
|
| 29 |
+
}
|
| 30 |
+
}, []);
|
| 31 |
+
|
| 32 |
+
useEffect(() => {
|
| 33 |
+
refresh();
|
| 34 |
+
}, [refresh]);
|
| 35 |
+
|
| 36 |
+
useEffect(() => {
|
| 37 |
+
const onAuth = () => refresh();
|
| 38 |
+
window.addEventListener('emailout-auth-changed', onAuth);
|
| 39 |
+
return () => window.removeEventListener('emailout-auth-changed', onAuth);
|
| 40 |
+
}, [refresh]);
|
| 41 |
+
|
| 42 |
+
const value = useMemo(
|
| 43 |
+
() => ({
|
| 44 |
+
phase,
|
| 45 |
+
googleOn,
|
| 46 |
+
user,
|
| 47 |
+
signedIn: !!user,
|
| 48 |
+
refresh,
|
| 49 |
+
}),
|
| 50 |
+
[phase, googleOn, user, refresh]
|
| 51 |
+
);
|
| 52 |
+
|
| 53 |
+
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
export function useAuth() {
|
| 57 |
+
const ctx = useContext(AuthContext);
|
| 58 |
+
if (!ctx) {
|
| 59 |
+
throw new Error('useAuth must be used within AuthProvider');
|
| 60 |
+
}
|
| 61 |
+
return ctx;
|
| 62 |
+
}
|
frontend/src/pages/Settings.jsx
CHANGED
|
@@ -203,10 +203,7 @@ export default function Settings() {
|
|
| 203 |
return (
|
| 204 |
<AppShell title="Settings">
|
| 205 |
<div className="rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-sm">
|
| 206 |
-
<p className="text-slate-600
|
| 207 |
-
<Button asChild variant="default">
|
| 208 |
-
<a href="/api/auth/google">Sign in with Google</a>
|
| 209 |
-
</Button>
|
| 210 |
</div>
|
| 211 |
</AppShell>
|
| 212 |
);
|
|
|
|
| 203 |
return (
|
| 204 |
<AppShell title="Settings">
|
| 205 |
<div className="rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-sm">
|
| 206 |
+
<p className="text-slate-600">Could not load your profile. Try signing out and back in.</p>
|
|
|
|
|
|
|
|
|
|
| 207 |
</div>
|
| 208 |
</AppShell>
|
| 209 |
);
|
frontend/src/pages/SignIn.jsx
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect } from 'react';
|
| 2 |
+
import { useSearchParams } from 'react-router-dom';
|
| 3 |
+
import { Loader2, Zap } from 'lucide-react';
|
| 4 |
+
import { Button } from '@/components/ui/button';
|
| 5 |
+
import { cn } from '@/lib/utils';
|
| 6 |
+
import { useAuth } from '@/context/AuthContext';
|
| 7 |
+
|
| 8 |
+
export default function SignIn() {
|
| 9 |
+
const { phase, googleOn } = useAuth();
|
| 10 |
+
const [searchParams, setSearchParams] = useSearchParams();
|
| 11 |
+
|
| 12 |
+
useEffect(() => {
|
| 13 |
+
const err = searchParams.get('auth_error');
|
| 14 |
+
if (!err) return;
|
| 15 |
+
if (err === 'access_denied') {
|
| 16 |
+
console.info('Google sign-in was cancelled.');
|
| 17 |
+
} else if (err === 'invite_email_mismatch') {
|
| 18 |
+
console.warn(
|
| 19 |
+
'Invitation email does not match your Google account. Sign in with the invited address.'
|
| 20 |
+
);
|
| 21 |
+
} else {
|
| 22 |
+
console.warn('Google sign-in error:', err);
|
| 23 |
+
}
|
| 24 |
+
const next = new URLSearchParams(searchParams);
|
| 25 |
+
next.delete('auth_error');
|
| 26 |
+
setSearchParams(next, { replace: true });
|
| 27 |
+
}, [searchParams, setSearchParams]);
|
| 28 |
+
|
| 29 |
+
const inviteParam = searchParams.get('invite');
|
| 30 |
+
const googleHref = inviteParam
|
| 31 |
+
? `/api/auth/google?invite=${encodeURIComponent(inviteParam)}`
|
| 32 |
+
: '/api/auth/google';
|
| 33 |
+
|
| 34 |
+
if (phase === 'loading') {
|
| 35 |
+
return (
|
| 36 |
+
<div className="flex min-h-[100dvh] items-center justify-center bg-gradient-to-br from-slate-50 via-white to-violet-50">
|
| 37 |
+
<Loader2 className="h-10 w-10 animate-spin text-violet-600" aria-label="Loading" />
|
| 38 |
+
</div>
|
| 39 |
+
);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
if (phase === 'error') {
|
| 43 |
+
return (
|
| 44 |
+
<div className="flex min-h-[100dvh] items-center justify-center bg-gradient-to-br from-slate-50 via-white to-violet-50 px-4">
|
| 45 |
+
<div className="max-w-md rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-sm">
|
| 46 |
+
<p className="text-slate-700">Could not reach the server. Refresh and try again.</p>
|
| 47 |
+
</div>
|
| 48 |
+
</div>
|
| 49 |
+
);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
if (!googleOn) {
|
| 53 |
+
return (
|
| 54 |
+
<div className="flex min-h-[100dvh] items-center justify-center bg-gradient-to-br from-slate-50 via-white to-violet-50 px-4">
|
| 55 |
+
<div className="max-w-md rounded-2xl border border-amber-200 bg-white p-8 text-center shadow-sm">
|
| 56 |
+
<p className="font-medium text-slate-800">Google sign-in is not configured</p>
|
| 57 |
+
<p className="mt-2 text-sm text-slate-600">
|
| 58 |
+
Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET on the server to enable access.
|
| 59 |
+
</p>
|
| 60 |
+
</div>
|
| 61 |
+
</div>
|
| 62 |
+
);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
return (
|
| 66 |
+
<div className="flex min-h-[100dvh] flex-col items-center justify-center bg-gradient-to-br from-slate-50 via-white to-violet-50 px-4 py-12">
|
| 67 |
+
<div className="w-full max-w-md rounded-2xl border border-slate-200 bg-white p-8 shadow-lg shadow-violet-100/50 sm:p-10">
|
| 68 |
+
<div className="mb-8 flex flex-col items-center text-center">
|
| 69 |
+
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-violet-600 to-purple-600 shadow-lg shadow-violet-200">
|
| 70 |
+
<Zap className="h-7 w-7 text-white" aria-hidden />
|
| 71 |
+
</div>
|
| 72 |
+
<h1 className="text-2xl font-bold text-slate-800">SequenceAI</h1>
|
| 73 |
+
<p className="mt-1 text-sm text-slate-500">CRM Workspace</p>
|
| 74 |
+
</div>
|
| 75 |
+
|
| 76 |
+
<p className="mb-6 text-center text-slate-600">
|
| 77 |
+
Sign in with your Google account to access campaigns, contacts, leads, and deals.
|
| 78 |
+
</p>
|
| 79 |
+
|
| 80 |
+
{inviteParam ? (
|
| 81 |
+
<p className="mb-6 rounded-lg bg-violet-50 px-4 py-3 text-center text-sm text-violet-800">
|
| 82 |
+
You have a workspace invitation. Sign in with the email address that received the
|
| 83 |
+
invite.
|
| 84 |
+
</p>
|
| 85 |
+
) : null}
|
| 86 |
+
|
| 87 |
+
<Button
|
| 88 |
+
asChild
|
| 89 |
+
size="lg"
|
| 90 |
+
className={cn(
|
| 91 |
+
'w-full gap-3 bg-white text-slate-800 border border-slate-200 shadow-sm',
|
| 92 |
+
'hover:bg-slate-50'
|
| 93 |
+
)}
|
| 94 |
+
variant="outline"
|
| 95 |
+
>
|
| 96 |
+
<a href={googleHref} className="inline-flex items-center justify-center gap-3">
|
| 97 |
+
<GoogleMark className="h-5 w-5 shrink-0" />
|
| 98 |
+
<span>Sign in with Google</span>
|
| 99 |
+
</a>
|
| 100 |
+
</Button>
|
| 101 |
+
</div>
|
| 102 |
+
</div>
|
| 103 |
+
);
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
function GoogleMark({ className }) {
|
| 107 |
+
return (
|
| 108 |
+
<svg className={className} viewBox="0 0 24 24" aria-hidden>
|
| 109 |
+
<path
|
| 110 |
+
fill="#4285F4"
|
| 111 |
+
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
| 112 |
+
/>
|
| 113 |
+
<path
|
| 114 |
+
fill="#34A853"
|
| 115 |
+
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
| 116 |
+
/>
|
| 117 |
+
<path
|
| 118 |
+
fill="#FBBC05"
|
| 119 |
+
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
| 120 |
+
/>
|
| 121 |
+
<path
|
| 122 |
+
fill="#EA4335"
|
| 123 |
+
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
| 124 |
+
/>
|
| 125 |
+
</svg>
|
| 126 |
+
);
|
| 127 |
+
}
|