From a3c31cd8dfe0a025343cc218b0e184b455f5f419 Mon Sep 17 00:00:00 2001 From: saurabh021120 <190030036.alum23@iitdh.ac.in> Date: Fri, 7 Aug 2026 17:14:02 +0530 Subject: [PATCH 1/2] Add link-based team invite flow (invite-only join) Lets an admin invite teammates into their company without email dependency. Backend (auth.py): - POST /auth/invite (admin): mints a tokenized invite, returns a shareable /invite/accept?token=... link (7-day expiry). Replaces the old Supabase invite_user_by_email (which needed SMTP and created passwordless rows). - GET /auth/invitation/{token} (public): preview company + email + role. - POST /auth/accept-invite (public): create the account + join the company as the invited role, then sign in. - GET /auth/members (admin): list the workspace. - 018_invites.sql: invites table (token, company, email, role, expiry, status), RLS on. main.py allowlists the two public invite endpoints. Frontend: - /invite/accept: invitee sets name + password (strength meter, show/hide), joins -> dashboard. - /team: admin invite form -> copyable link + member list. Team nav link added. Co-Authored-By: Claude Opus 4.8 --- backend/main.py | 5 + backend/src/routes/auth.py | 141 +++++++++++++-- backend/supabase/migrations/018_invites.sql | 27 +++ frontend/src/App.js | 4 + frontend/src/components/Nav.jsx | 3 +- frontend/src/pages/AcceptInvite.jsx | 189 ++++++++++++++++++++ frontend/src/pages/Team.jsx | 149 +++++++++++++++ 7 files changed, 506 insertions(+), 12 deletions(-) create mode 100644 backend/supabase/migrations/018_invites.sql create mode 100644 frontend/src/pages/AcceptInvite.jsx create mode 100644 frontend/src/pages/Team.jsx diff --git a/backend/main.py b/backend/main.py index 7764a46..78f9020 100644 --- a/backend/main.py +++ b/backend/main.py @@ -259,8 +259,13 @@ def _is_public_api(method: str, path: str) -> bool: if path in ( '/api/auth/signup', '/api/auth/login', '/api/auth/logout', '/api/auth/refresh', '/api/auth/forgot-password', '/api/auth/reset-password', + '/api/auth/accept-invite', # invitee has no account yet ): return True + # Public invitation preview (GET /api/auth/invitation/). Note: POST + # /api/auth/invite (admin create) is deliberately NOT here and stays protected. + if method == 'GET' and path.startswith('/api/auth/invitation/'): + return True # Public early-access capture from the marketing page. GET /api/leads # (admin listing) is deliberately NOT allowlisted and stays protected. if path == '/api/leads' and method == 'POST': diff --git a/backend/src/routes/auth.py b/backend/src/routes/auth.py index 9eaa7d1..87f5206 100644 --- a/backend/src/routes/auth.py +++ b/backend/src/routes/auth.py @@ -1,4 +1,6 @@ import os +import secrets +from datetime import datetime, timezone, timedelta from fastapi import APIRouter, Depends, Request from fastapi.responses import JSONResponse from pydantic import BaseModel @@ -45,6 +47,22 @@ class InviteBody(BaseModel): role: str = 'member' +class AcceptInviteBody(BaseModel): + token: str + name: str + password: str + + +INVITE_TTL_DAYS = 7 + + +def _is_expired(ts) -> bool: + try: + return datetime.fromisoformat(str(ts).replace('Z', '+00:00')) < datetime.now(timezone.utc) + except Exception: + return False + + def _session_payload(resp) -> dict: session = getattr(resp, 'session', None) user = getattr(resp, 'user', None) or (getattr(session, 'user', None) if session else None) @@ -200,29 +218,130 @@ async def me(user: AuthUser = Depends(get_current_user)): } +@router.get('/auth/members') +async def list_members(user: AuthUser = Depends(get_current_user)): + """Active members of the caller's company (admin/owner only).""" + require_admin(user) + if not user.company_id: + return JSONResponse(status_code=400, content={'error': 'No company context.'}) + db = get_client() + try: + rows = db.table('team_members').select('user_id, email, role, created_at') \ + .eq('company_id', user.company_id).eq('is_active', True) \ + .order('created_at').execute().data + return rows + except Exception as e: + print(f'[auth] list_members error: {e}') + return JSONResponse(status_code=500, content={'error': 'Could not load members.'}) + + @router.post('/auth/invite') async def invite(body: InviteBody, request: Request, user: AuthUser = Depends(get_current_user)): + """Admin creates a link-based invitation. Returns a shareable invite URL.""" require_admin(user) if not user.company_id: return JSONResponse(status_code=400, content={'error': 'You must belong to a company before inviting members.'}) + email = (body.email or '').strip().lower() + if not email or '@' not in email: + return JSONResponse(status_code=422, content={'error': 'A valid email address is required.'}) + role = body.role if body.role in ('admin', 'member') else 'member' db = get_client() try: - res = supabase_admin.auth.admin.invite_user_by_email(body.email) - invited = getattr(res, 'user', None) or res - invited_id = getattr(invited, 'id', None) + already = db.table('team_members').select('id').eq('company_id', user.company_id) \ + .eq('email', email).eq('is_active', True).execute().data + if already: + return JSONResponse(status_code=409, content={'error': 'That person is already in your workspace.'}) + except Exception: + pass + token = secrets.token_urlsafe(32) + expires = (datetime.now(timezone.utc) + timedelta(days=INVITE_TTL_DAYS)).isoformat() + try: + db.table('invites').insert({ + 'company_id': user.company_id, 'email': email, 'role': role, 'token': token, + 'invited_by': user.user_id, 'status': 'pending', 'expires_at': expires, + }).execute() + except Exception as e: + print(f'[auth] invite create error: {e}') + return JSONResponse(status_code=500, content={'error': 'Could not create the invitation. Please try again.'}) + link = f"{FRONTEND_URL}/invite/accept?token={token}" + await log_action(user, 'auth.member_invited', 'invite', None, + metadata={'email': email, 'role': role}, request=request) + return {'ok': True, 'email': email, 'role': role, 'invite_link': link, 'expires_at': expires} + + +@router.get('/auth/invitation/{token}') +async def invitation_info(token: str): + """Public: preview an invitation (company + email + role) for the accept page.""" + db = get_client() + try: + rows = db.table('invites').select('email, role, status, expires_at, company_id') \ + .eq('token', token).limit(1).execute().data + except Exception as e: + print(f'[auth] invitation lookup error: {e}') + rows = None + if not rows: + return JSONResponse(status_code=404, content={'error': 'This invitation link is invalid.'}) + inv = rows[0] + if inv.get('status') != 'pending': + return JSONResponse(status_code=410, content={'error': 'This invitation has already been used or was revoked.'}) + if _is_expired(inv.get('expires_at')): + return JSONResponse(status_code=410, content={'error': 'This invitation has expired. Ask your admin for a new one.'}) + company = db.table('company_profiles').select('company_name').eq('id', inv['company_id']).limit(1).execute().data + return { + 'email': inv['email'], 'role': inv['role'], + 'company_name': (company[0]['company_name'] if company else 'your team'), + } + + +@router.post('/auth/accept-invite') +@limiter.limit('5/minute') +async def accept_invite(body: AcceptInviteBody, request: Request): + """Public: accept an invitation — create the account and join the company.""" + db = get_client() + try: + rows = db.table('invites').select('*').eq('token', body.token).limit(1).execute().data + except Exception as e: + print(f'[auth] accept lookup error: {e}') + rows = None + if not rows: + return JSONResponse(status_code=404, content={'error': 'This invitation link is invalid.'}) + inv = rows[0] + if inv.get('status') != 'pending': + return JSONResponse(status_code=410, content={'error': 'This invitation has already been used or was revoked.'}) + if _is_expired(inv.get('expires_at')): + return JSONResponse(status_code=410, content={'error': 'This invitation has expired. Ask your admin for a new one.'}) + email = inv['email'] + name = (body.name or '').strip() + if not name or len(body.password or '') < 8: + return JSONResponse(status_code=422, content={'error': 'A name and a password of at least 8 characters are required.'}) + try: + res = supabase_admin.auth.admin.create_user({ + 'email': email, 'password': body.password, 'email_confirm': True, + 'user_metadata': {'name': name}, + }) + uid = getattr(getattr(res, 'user', None) or res, 'id', None) except Exception as e: - print(f'[auth] invite error: {e}') - invited_id = None + msg = str(e).lower() + if 'already' in msg or 'registered' in msg or 'exists' in msg: + return JSONResponse(status_code=409, content={'error': 'You already have an account — please sign in instead.'}) + print(f'[auth] accept create_user error: {e}') + return JSONResponse(status_code=400, content={'error': 'Could not create your account. Please try again.'}) + if not uid: + return JSONResponse(status_code=400, content={'error': 'Could not create your account. Please try again.'}) try: db.table('team_members').upsert({ - 'company_id': user.company_id, 'user_id': invited_id, 'email': body.email, - 'role': body.role if body.role in ('admin', 'member') else 'member', 'is_active': True, + 'company_id': inv['company_id'], 'user_id': uid, 'email': email, + 'role': inv['role'], 'is_active': True, }, on_conflict='company_id,user_id').execute() + db.table('invites').update({'status': 'accepted'}).eq('id', inv['id']).execute() except Exception as e: - print(f'[auth] invite member row error: {e}') - await log_action(user, 'auth.member_invited', 'user', invited_id, - metadata={'email': body.email, 'role': body.role}, request=request) - return {'ok': True, 'message': f'Invitation sent to {body.email}.'} + print(f'[auth] accept join error: {e}') + return JSONResponse(status_code=500, content={'error': 'Your account was created but joining the workspace failed. Contact your admin.'}) + # Fresh client (never sign in on the shared supabase_admin singleton). + session = _session_payload(get_client().auth.sign_in_with_password({'email': email, 'password': body.password})) + await log_action(AuthUser(user_id=uid, email=email, company_id=inv['company_id'], role=inv['role']), + 'auth.invite_accepted', 'company', inv['company_id'], request=request) + return {**session, 'role': inv['role']} @router.delete('/auth/members/{member_user_id}') diff --git a/backend/supabase/migrations/018_invites.sql b/backend/supabase/migrations/018_invites.sql new file mode 100644 index 0000000..0cf49b7 --- /dev/null +++ b/backend/supabase/migrations/018_invites.sql @@ -0,0 +1,27 @@ +-- 018_invites.sql — link-based team invitations (invite-only join) +-- An admin creates an invite (email + role) which mints a token. The invitee opens +-- /invite/accept?token=..., sets name + password, and joins the company as a member. +-- Self-contained (no email dependency); the admin shares the returned link. + +do $$ +begin + create table if not exists invites ( + id uuid primary key default gen_random_uuid(), + company_id uuid not null references company_profiles(id) on delete cascade, + email text not null, + role text not null default 'member' check (role in ('admin','member')), + token text not null unique, + invited_by uuid, + status text not null default 'pending' check (status in ('pending','accepted','revoked')), + expires_at timestamptz, + created_at timestamptz default now() + ); + create index if not exists invites_token_idx on invites (token); + create index if not exists invites_company_idx on invites (company_id); + + -- Backend-only table (service-role). RLS on with no policy blocks direct access. + alter table invites enable row level security; +end $$; + +-- ================= ROLLBACK ================= +-- drop table if exists invites; diff --git a/frontend/src/App.js b/frontend/src/App.js index 00b20d7..e3d6769 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -4,6 +4,8 @@ import ErrorBoundary from "@/components/ErrorBoundary"; import Home from "@/pages/Home"; import Login from "@/pages/Login"; import Signup from "@/pages/Signup"; +import AcceptInvite from "@/pages/AcceptInvite"; +import Team from "@/pages/Team"; import Onboarding from "@/pages/Onboarding"; import Dashboard from "@/pages/Dashboard"; import Knowledge from "@/pages/Knowledge"; @@ -52,6 +54,8 @@ function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/Nav.jsx b/frontend/src/components/Nav.jsx index 15114bd..6928197 100644 --- a/frontend/src/components/Nav.jsx +++ b/frontend/src/components/Nav.jsx @@ -1,7 +1,7 @@ import { Link, useLocation, useNavigate } from 'react-router-dom' import { LayoutDashboard, BookOpen, Search, Bell, AlertTriangle, - Users, TrendingUp, Code2, BarChart3, Radar, Waypoints, Zap, Plug, + Users, TrendingUp, Code2, BarChart3, Radar, Waypoints, Zap, Plug, UserPlus, } from 'lucide-react' import { signOut } from '../lib/supabase' import { useAuth } from '../hooks/useAuth' @@ -26,6 +26,7 @@ const SECTIONS = [ { title: 'Organization', links: [ { to: '/people', label: 'People', icon: Users }, { to: '/executive', label: 'Executive', icon: TrendingUp }, + { to: '/team', label: 'Team', icon: UserPlus }, ]}, { title: 'Developers', links: [ { to: '/connections', label: 'Connections', icon: Plug }, diff --git a/frontend/src/pages/AcceptInvite.jsx b/frontend/src/pages/AcceptInvite.jsx new file mode 100644 index 0000000..1e1b61c --- /dev/null +++ b/frontend/src/pages/AcceptInvite.jsx @@ -0,0 +1,189 @@ +import { useEffect, useMemo, useState } from 'react' +import { useNavigate, useSearchParams, Link } from 'react-router-dom' +import { Eye, EyeOff, Check, X as XIcon } from 'lucide-react' +import { api } from '../lib/api' +import { signIn } from '../lib/supabase' + +function scorePassword(pw) { + if (!pw) return 0 + let s = 0 + if (pw.length >= 8) s++ + if (pw.length >= 12) s++ + if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) s++ + if (/\d/.test(pw)) s++ + if (/[^A-Za-z0-9]/.test(pw)) s++ + return Math.min(s, 4) +} +const STRENGTH = [ + { label: 'Too weak', bar: 'bg-red-500', text: 'text-red-400' }, + { label: 'Weak', bar: 'bg-orange-500', text: 'text-orange-400' }, + { label: 'Fair', bar: 'bg-yellow-500', text: 'text-yellow-400' }, + { label: 'Good', bar: 'bg-emerald-500', text: 'text-emerald-400' }, + { label: 'Strong', bar: 'bg-emerald-400', text: 'text-emerald-300' }, +] + +export default function AcceptInvite() { + const navigate = useNavigate() + const [params] = useSearchParams() + const token = params.get('token') || '' + + const [invite, setInvite] = useState(null) // { email, role, company_name } + const [loadErr, setLoadErr] = useState('') + const [checking, setChecking] = useState(true) + + const [name, setName] = useState('') + const [password, setPassword] = useState('') + const [confirm, setConfirm] = useState('') + const [showPw, setShowPw] = useState(false) + const [showConfirm, setShowConfirm] = useState(false) + const [submitted, setSubmitted] = useState(false) + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + useEffect(() => { + let alive = true + ;(async () => { + if (!token) { setLoadErr('This invitation link is missing its token.'); setChecking(false); return } + try { + const { data } = await api.get(`/auth/invitation/${token}`) + if (alive) setInvite(data) + } catch (err) { + if (alive) setLoadErr(err?.response?.data?.error || 'This invitation link is invalid or has expired.') + } finally { + if (alive) setChecking(false) + } + })() + return () => { alive = false } + }, [token]) + + const pwScore = useMemo(() => scorePassword(password), [password]) + const meetsPolicy = password.length >= 8 && /[A-Za-z]/.test(password) && /\d/.test(password) + const confirmMatches = confirm.length > 0 && confirm === password + const errors = { + name: name.trim() ? '' : 'Please enter your name.', + password: meetsPolicy ? '' : 'At least 8 characters, with a letter and a number.', + confirm: confirmMatches ? '' : 'Passwords do not match.', + } + const isValid = Object.values(errors).every((e) => !e) + + async function handleSubmit(e) { + e.preventDefault() + setSubmitted(true) + setError('') + if (!isValid) return + setLoading(true) + try { + await api.post('/auth/accept-invite', { token, name: name.trim(), password }) + await signIn(invite.email, password) // establish the Supabase session + navigate('/dashboard') + } catch (err) { + const status = err?.response?.status + const msg = err?.response?.data?.error + if (status === 409) setError('You already have an account — please sign in instead.') + else if (status === 410) setError(msg || 'This invitation is no longer valid.') + else setError(typeof msg === 'string' ? msg : 'Could not accept the invitation. Please try again.') + } finally { + setLoading(false) + } + } + + const shell = (inner) => ( +
+
+
+

CortexLoop

+

Accept your invitation

+
+ {inner} +
+
+ ) + + if (checking) { + return shell(
// checking invitation…
) + } + if (loadErr) { + return shell( +
+
{loadErr}
+ Go to sign in +
+ ) + } + + const fieldClass = (bad) => + `w-full bg-gray-950 border rounded-lg px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-gray-600 ${bad ? 'border-red-800' : 'border-gray-800'}` + + return shell( +
+
+ You've been invited to join{' '} + {invite.company_name} + {' '}as {invite.role === 'admin' ? 'an admin' : 'a member'}. +
+ +
+ + +
+ +
+ + setName(e.target.value)} + className={fieldClass(submitted && errors.name)} placeholder="Ada Lovelace" autoComplete="name" /> + {submitted && errors.name &&

{errors.name}

} +
+ +
+ +
+ setPassword(e.target.value)} + className={`${fieldClass(submitted && errors.password)} pr-10`} placeholder="••••••••" autoComplete="new-password" /> + +
+ {password && ( +
+
+ {[0, 1, 2, 3].map((i) => ( +
+ ))} +
+

{STRENGTH[pwScore].label}

+
+ )} + {submitted && errors.password &&

{errors.password}

} +
+ +
+ +
+ setConfirm(e.target.value)} + className={`${fieldClass(submitted && errors.confirm)} pr-16`} placeholder="••••••••" autoComplete="new-password" /> + {confirm && ( + + {confirmMatches ? : } + + )} + +
+ {submitted && errors.confirm &&

{errors.confirm}

} +
+ + {error &&
{error}
} + + + + ) +} diff --git a/frontend/src/pages/Team.jsx b/frontend/src/pages/Team.jsx new file mode 100644 index 0000000..fa2a3e1 --- /dev/null +++ b/frontend/src/pages/Team.jsx @@ -0,0 +1,149 @@ +import { useEffect, useState } from 'react' +import Nav from '../components/Nav' +import { api } from '../lib/api' +import { Copy, Check, UserPlus } from 'lucide-react' + +export default function Team() { + const [me, setMe] = useState(null) // null = loading, false = error + const [members, setMembers] = useState([]) + const [email, setEmail] = useState('') + const [role, setRole] = useState('member') + const [invite, setInvite] = useState(null) // { invite_link, email, role } + const [error, setError] = useState('') + const [busy, setBusy] = useState(false) + const [copied, setCopied] = useState(false) + + const isAdmin = me && (me.role === 'admin' || me.role === 'owner') + + useEffect(() => { + let alive = true + ;(async () => { + try { + const { data } = await api.get('/auth/me') + if (alive) setMe(data) + } catch { if (alive) setMe(false) } + })() + return () => { alive = false } + }, []) + + useEffect(() => { if (isAdmin) loadMembers() /* eslint-disable-next-line */ }, [isAdmin]) + + async function loadMembers() { + try { + const { data } = await api.get('/auth/members') + setMembers(Array.isArray(data) ? data : []) + } catch (e) { /* non-fatal */ } + } + + async function createInvite(e) { + e.preventDefault() + setBusy(true); setError(''); setInvite(null); setCopied(false) + try { + const { data } = await api.post('/auth/invite', { email: email.trim().toLowerCase(), role }) + setInvite(data) + setEmail('') + loadMembers() + } catch (err) { + setError(err?.response?.data?.error || 'Could not create the invitation.') + } finally { + setBusy(false) + } + } + + async function copyLink() { + try { + await navigator.clipboard.writeText(invite.invite_link) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch { /* clipboard blocked — user can select manually */ } + } + + return ( +
+
+ ) +} From 6000fee95ac07922b50050940c5b0ce95e5ccc9c Mon Sep 17 00:00:00 2001 From: saurabh021120 <190030036.alum23@iitdh.ac.in> Date: Fri, 7 Aug 2026 17:19:47 +0530 Subject: [PATCH 2/2] Team page: align invite-row select with input and button Native setEmail(e.target.value)} placeholder="teammate@company.com" autoComplete="off" - className="flex-1 bg-gray-950 border border-gray-800 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-gray-600" + className="flex-1 h-10 bg-gray-950 border border-gray-800 rounded-lg px-3 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-gray-600" />