Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<token>). 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':
Expand Down
141 changes: 130 additions & 11 deletions backend/src/routes/auth.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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}')
Expand Down
27 changes: 27 additions & 0 deletions backend/supabase/migrations/018_invites.sql
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 4 additions & 0 deletions frontend/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -52,6 +54,8 @@ function App() {
<Route path="/" element={<ErrorBoundary><Home /></ErrorBoundary>} />
<Route path="/login" element={<ErrorBoundary><Login /></ErrorBoundary>} />
<Route path="/signup" element={<ErrorBoundary><Signup /></ErrorBoundary>} />
<Route path="/invite/accept" element={<ErrorBoundary><AcceptInvite /></ErrorBoundary>} />
<Route path="/team" element={<ProtectedRoute><Team /></ProtectedRoute>} />
<Route path="/onboarding" element={<ProtectedRoute><Onboarding /></ProtectedRoute>} />
<Route path="/dashboard" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
<Route path="/knowledge" element={<ProtectedRoute><Knowledge /></ProtectedRoute>} />
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/Nav.jsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 },
Expand Down
Loading