2026-08-09 22:47:43 +02:00
package httpapi
import (
"context"
"errors"
"net/http"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/google/uuid"
)
func ( s * Server ) handleRegister ( w http . ResponseWriter , r * http . Request ) {
var body struct {
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
CompanyName string `json:"company_name"`
}
if err := DecodeJSON ( r , & body ); err != nil {
Error ( w , http . StatusBadRequest , "invalid json" )
return
}
res , err := s . Auth . Register ( r . Context (), auth . RegisterInput {
Email : body . Email , Password : body . Password , Name : body . Name , CompanyName : body . CompanyName ,
})
if err != nil {
if errors . Is ( err , auth . ErrUserExists ) {
FieldError ( w , http . StatusConflict , "user already exists" , "user_already_exists" , map [ string ] string {
"email" : "user already exists" ,
})
return
}
if errors . Is ( err , auth . ErrPasswordTooShort ) {
FieldError ( w , http . StatusBadRequest , "password must be at least 8 characters" , "password_too_short" , map [ string ] string {
"password" : "password must be at least 8 characters" ,
})
return
}
if errors . Is ( err , auth . ErrRegisterFieldsRequired ) {
FieldError ( w , http . StatusBadRequest , "email, password, and company name are required" , "register_fields_required" , map [ string ] string {
"email" : "email, password, and company name are required" ,
"password" : "email, password, and company name are required" ,
"company_name" : "email, password, and company name are required" ,
})
return
}
ClientOrLog ( w , http . StatusBadRequest , "registration failed" , err , auth . ClientError )
return
}
_ = s . Billing . ProvisionFreePlan ( r . Context (), res . CompanyID )
if err := s . beginAuthenticatedSession ( r . Context (), res . User . ID , res . CompanyID ); err != nil {
Error ( w , http . StatusInternalServerError , "session start failed" )
return
}
JSON ( w , http . StatusCreated , res )
}
func ( s * Server ) handleLogin ( w http . ResponseWriter , r * http . Request ) {
var body struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := DecodeJSON ( r , & body ); err != nil {
Error ( w , http . StatusBadRequest , "invalid json" )
return
}
lockout := s . loginAttempts ()
if locked , retryAfter := lockout . locked ( body . Email ); locked {
writeRateLimited ( w , loginLockoutMaxFails , retryAfter )
return
}
res , err := s . Auth . Login ( r . Context (), body . Email , body . Password )
if errors . Is ( err , auth . ErrMustSetPassword ) {
JSON ( w , http . StatusForbidden , map [ string ] string {
"error" : "password_not_set" ,
"code" : "password_not_set" ,
"message" : PublicMessage ( w , "This account still needs a password. Open your set-password invite link, or ask a company admin to re-issue one to this email." ),
})
return
}
if errors . Is ( err , auth . ErrInvalidCredentials ) {
lockout . recordFailure ( body . Email )
if locked , retryAfter := lockout . locked ( body . Email ); locked {
writeRateLimited ( w , loginLockoutMaxFails , retryAfter )
return
}
FieldError ( w , http . StatusUnauthorized , "invalid credentials" , "invalid_credentials" , map [ string ] string {
"email" : "invalid credentials" ,
"password" : "invalid credentials" ,
})
return
}
if err != nil {
Error ( w , http . StatusInternalServerError , "login failed" )
return
}
lockout . clear ( body . Email )
if err := s . beginAuthenticatedSession ( r . Context (), res . User . ID , res . CompanyID ); err != nil {
Error ( w , http . StatusInternalServerError , "session start failed" )
return
}
JSON ( w , http . StatusOK , res )
}
// sessionUserEmail returns the signed-in user's email when a session cookie is present.
func ( s * Server ) sessionUserEmail ( ctx context . Context ) ( string , bool ) {
uidStr := s . Sessions . GetString ( ctx , auth . SessionUserIDKey )
if uidStr == "" {
return "" , false
}
uid , err := uuid . Parse ( uidStr )
if err != nil {
return "" , false
}
user , err := s . Auth . GetUser ( ctx , uid )
if err != nil {
return "" , false
}
email := strings . TrimSpace ( user . Email )
if email == "" {
return "" , false
}
return email , true
}
func writeEmailMismatch ( w http . ResponseWriter , sessionEmail , inviteEmail string ) {
JSON ( w , http . StatusConflict , map [ string ] string {
"error" : "email_mismatch" ,
"code" : "email_mismatch" ,
"message" : PublicMessage ( w , "You're signed in as a different email than this invite. Sign out to continue with the invited account, or ask an admin to re-issue the invite to your signed-in email." ),
"session_email" : sessionEmail ,
"invite_email" : inviteEmail ,
})
}
func ( s * Server ) beginAuthenticatedSession ( ctx context . Context , userID , companyID uuid . UUID ) error {
if err := s . Sessions . RenewToken ( ctx ); err != nil {
return err
}
s . Sessions . Put ( ctx , auth . SessionUserIDKey , userID . String ())
s . putSessionVersion ( ctx , userID )
2026-08-14 00:06:43 +02:00
// Fresh login/register clears any prior impersonation chain / staff tenant act-as.
2026-08-09 22:47:43 +02:00
s . Sessions . Remove ( ctx , auth . SessionImpersonatorIDKey )
2026-08-14 00:06:43 +02:00
s . Sessions . Remove ( ctx , auth . SessionStaffHomeCompanyKey )
2026-08-09 22:47:43 +02:00
if companyID == uuid . Nil {
s . Sessions . Put ( ctx , auth . SessionCompanyIDKey , "" )
return nil
}
s . Sessions . Put ( ctx , auth . SessionCompanyIDKey , companyID . String ())
return nil
}
// beginImpersonatedSession swaps the signed-in user while preserving the original actor.
func ( s * Server ) beginImpersonatedSession ( ctx context . Context , targetUserID , companyID , actorID uuid . UUID ) error {
if err := s . Sessions . RenewToken ( ctx ); err != nil {
return err
}
s . Sessions . Put ( ctx , auth . SessionUserIDKey , targetUserID . String ())
s . putSessionVersion ( ctx , targetUserID )
// Keep the original impersonator across chained switches.
if existing := strings . TrimSpace ( s . Sessions . GetString ( ctx , auth . SessionImpersonatorIDKey )); existing == "" {
s . Sessions . Put ( ctx , auth . SessionImpersonatorIDKey , actorID . String ())
}
if companyID == uuid . Nil {
s . Sessions . Put ( ctx , auth . SessionCompanyIDKey , "" )
return nil
}
s . Sessions . Put ( ctx , auth . SessionCompanyIDKey , companyID . String ())
return nil
}
// putSessionVersion stamps users.session_version into the cookie session (0 when DB unavailable).
func ( s * Server ) putSessionVersion ( ctx context . Context , userID uuid . UUID ) {
version := 0
if s != nil && s . Auth != nil && s . Auth . Pool != nil {
if st , err := s . Auth . UserSessionState ( ctx , userID ); err == nil {
version = st . Version
}
}
s . Sessions . Put ( ctx , auth . SessionVersionKey , version )
}
func ( s * Server ) handleLogout ( w http . ResponseWriter , r * http . Request ) {
if err := s . Sessions . Destroy ( r . Context ()); err != nil {
Error ( w , http . StatusInternalServerError , "logout failed" )
return
}
JSON ( w , http . StatusOK , map [ string ] string { "status" : "ok" })
}
func ( s * Server ) handleInvitePreview ( w http . ResponseWriter , r * http . Request ) {
var body struct {
Token string `json:"token"`
Mode string `json:"mode"`
}
if err := DecodeJSON ( r , & body ); err != nil {
Error ( w , http . StatusBadRequest , "invalid json" )
return
}
mode := strings . TrimSpace ( strings . ToLower ( body . Mode ))
if mode == "" {
mode = "invite"
}
var inviteEmail string
switch mode {
case "set-password" :
uid , err := auth . ParseSetPasswordToken ( s . Config . TokenSigningSecret , body . Token )
if err != nil {
Error ( w , http . StatusBadRequest , "invalid or expired token" )
return
}
user , err := s . Auth . GetUser ( r . Context (), uid )
if err != nil {
Error ( w , http . StatusBadRequest , "invalid or expired token" )
return
}
inviteEmail = user . Email
default :
mode = "invite"
email , err := s . Auth . ResolveInviteEmail ( r . Context (), body . Token )
if errors . Is ( err , auth . ErrInviteInvalid ) {
Error ( w , http . StatusBadRequest , "invite invalid or expired" )
return
}
if err != nil {
ClientOrLog ( w , http . StatusBadRequest , "invite preview failed" , err , auth . ClientError )
return
}
inviteEmail = email
}
out := map [ string ] any {
"mode" : mode ,
"invite_email" : inviteEmail ,
"valid" : true ,
"mismatch" : false ,
}
if sessionEmail , ok := s . sessionUserEmail ( r . Context ()); ok {
out [ "session_email" ] = sessionEmail
if ! auth . EmailsEqual ( sessionEmail , inviteEmail ) {
out [ "mismatch" ] = true
}
}
JSON ( w , http . StatusOK , out )
}
func ( s * Server ) handleAcceptInvite ( w http . ResponseWriter , r * http . Request ) {
var body struct {
Token string `json:"token"`
Password string `json:"password"`
Name string `json:"name"`
}
if err := DecodeJSON ( r , & body ); err != nil {
Error ( w , http . StatusBadRequest , "invalid json" )
return
}
if sessionEmail , ok := s . sessionUserEmail ( r . Context ()); ok {
inviteEmail , err := s . Auth . ResolveInviteEmail ( r . Context (), body . Token )
if errors . Is ( err , auth . ErrInviteInvalid ) {
Error ( w , http . StatusBadRequest , "invite invalid or expired" )
return
}
if err != nil {
ClientOrLog ( w , http . StatusBadRequest , "invite accept failed" , err , auth . ClientError )
return
}
if ! auth . EmailsEqual ( sessionEmail , inviteEmail ) {
writeEmailMismatch ( w , sessionEmail , inviteEmail )
return
}
}
res , err := s . Auth . AcceptInvite ( r . Context (), body . Token , body . Password , body . Name )
if errors . Is ( err , auth . ErrInviteInvalid ) {
Error ( w , http . StatusBadRequest , "invite invalid or expired" )
return
}
if errors . Is ( err , auth . ErrInvalidCredentials ) {
FieldError ( w , http . StatusUnauthorized , "invalid credentials" , "invalid_credentials" , map [ string ] string {
"password" : "invalid credentials" ,
})
return
}
if err != nil {
ClientOrLog ( w , http . StatusBadRequest , "invite accept failed" , err , auth . ClientError )
return
}
if err := s . beginAuthenticatedSession ( r . Context (), res . User . ID , res . CompanyID ); err != nil {
Error ( w , http . StatusInternalServerError , "session start failed" )
return
}
JSON ( w , http . StatusOK , res )
}
func ( s * Server ) handleMe ( w http . ResponseWriter , r * http . Request ) {
uid , _ := UserIDFromContext ( r . Context ())
user , err := s . Auth . GetUser ( r . Context (), uid )
if err != nil {
Error ( w , http . StatusUnauthorized , "unauthorized" )
return
}
companies , err := s . Auth . ListUserCompanies ( r . Context (), uid )
if err != nil {
Error ( w , http . StatusInternalServerError , "failed to load companies" )
return
}
2026-08-14 00:06:43 +02:00
staffTenantSwitch := false
access , accessErr := s . Auth . GetStaffAccess ( r . Context (), uid )
if accessErr == nil && access . FullAdmin && access . Role == auth . StaffRoleAdmin {
staffTenantSwitch = true
all , listErr := s . Auth . ListCompanies ( r . Context (), 500 )
if listErr != nil {
Error ( w , http . StatusInternalServerError , "failed to load companies" )
return
}
companies = all
}
2026-08-09 22:47:43 +02:00
cidStr := s . Sessions . GetString ( r . Context (), auth . SessionCompanyIDKey )
2026-08-14 00:06:43 +02:00
homeStr := strings . TrimSpace ( s . Sessions . GetString ( r . Context (), auth . SessionStaffHomeCompanyKey ))
2026-08-09 22:47:43 +02:00
out := map [ string ] any {
"user" : user ,
"companies" : companies ,
"active_company_id" : cidStr ,
}
2026-08-14 00:06:43 +02:00
if accessErr == nil && ( access . FullAdmin || access . SupportDesk ) {
2026-08-09 22:47:43 +02:00
out [ "staff_access" ] = access
out [ "staff_capabilities" ] = auth . StaffCapabilities ( access . Role )
}
2026-08-14 00:06:43 +02:00
if staffTenantSwitch {
out [ "staff_tenant_switch" ] = true
}
staffOverride := false
2026-08-09 22:47:43 +02:00
if cid , err := uuid . Parse ( cidStr ); err == nil {
for _ , c := range companies {
if c . ID == cid {
out [ "company" ] = c
break
}
}
2026-08-14 00:06:43 +02:00
// Staff may have selected a company not in the capped list — resolve by id.
if _ , ok := out [ "company" ]; ! ok && staffTenantSwitch {
if c , cerr := s . Auth . CompanyByID ( r . Context (), cid ); cerr == nil {
out [ "company" ] = c
}
}
2026-08-09 22:47:43 +02:00
if credits , err := s . Billing . CreditsOverview ( r . Context (), cid , s . Config . LowCreditsThreshold ); err == nil {
out [ "credits" ] = credits
}
if m , err := s . Auth . EnsureMembership ( r . Context (), uid , cid ); err == nil {
out [ "membership" ] = map [ string ] string { "role" : m . Role , "status" : m . Status }
2026-08-14 00:06:43 +02:00
} else if staffTenantSwitch && errors . Is ( err , auth . ErrNotCompanyMember ) {
staffOverride = true
out [ "membership" ] = map [ string ] any { "role" : "admin" , "status" : "active" , "staff_override" : true }
2026-08-09 22:47:43 +02:00
}
}
2026-08-14 00:06:43 +02:00
if staffTenantSwitch && homeStr != "" {
out [ "staff_home_company_id" ] = homeStr
if hid , err := uuid . Parse ( homeStr ); err == nil {
if c , cerr := s . Auth . CompanyByID ( r . Context (), hid ); cerr == nil {
out [ "staff_home_company" ] = c
}
}
}
if staffOverride {
out [ "staff_tenant_acting" ] = true
}
2026-08-09 22:47:43 +02:00
impersonating := false
if impStr := strings . TrimSpace ( s . Sessions . GetString ( r . Context (), auth . SessionImpersonatorIDKey )); impStr != "" {
if impID , err := uuid . Parse ( impStr ); err == nil && impID != uuid . Nil {
impersonating = true
out [ "impersonating" ] = true
if impUser , err := s . Auth . GetUser ( r . Context (), impID ); err == nil {
out [ "impersonator" ] = map [ string ] any {
"id" : impUser . ID ,
"email" : impUser . Email ,
"name" : impUser . Name ,
}
} else {
out [ "impersonator" ] = map [ string ] any { "id" : impID }
}
}
}
2026-08-14 01:38:34 +02:00
canUserSwitch := impersonating
if ! canUserSwitch {
if accessErr == nil && access . FullAdmin && access . Role == auth . StaffRoleAdmin {
canUserSwitch = true
} else if ! s . Config . IsProduction () {
2026-08-14 00:06:43 +02:00
if accessErr == nil && access . FullAdmin {
2026-08-14 01:38:34 +02:00
canUserSwitch = true
2026-08-09 22:47:43 +02:00
} else if isLocalDemoEmail ( user . Email ) {
2026-08-14 01:38:34 +02:00
canUserSwitch = true
2026-08-09 22:47:43 +02:00
}
}
2026-08-14 01:38:34 +02:00
}
if canUserSwitch {
out [ "dev_user_switch" ] = true
2026-08-09 22:47:43 +02:00
}
JSON ( w , http . StatusOK , out )
}
func ( s * Server ) handleSetPassword ( w http . ResponseWriter , r * http . Request ) {
uid , _ := UserIDFromContext ( r . Context ())
var body struct {
Password string `json:"password"`
}
if err := DecodeJSON ( r , & body ); err != nil {
Error ( w , http . StatusBadRequest , "invalid json" )
return
}
if err := s . Auth . SetPassword ( r . Context (), uid , body . Password ); err != nil {
if errors . Is ( err , auth . ErrPasswordAlreadySet ) {
Error ( w , http . StatusBadRequest , "password already set" )
return
}
ClientOrLog ( w , http . StatusBadRequest , "could not set password" , err , auth . ClientError )
return
}
s . putSessionVersion ( r . Context (), uid )
JSON ( w , http . StatusOK , map [ string ] string { "status" : "ok" })
}
func ( s * Server ) handleChangePassword ( w http . ResponseWriter , r * http . Request ) {
uid , _ := UserIDFromContext ( r . Context ())
var body struct {
CurrentPassword string `json:"current_password"`
Password string `json:"password"`
}
if err := DecodeJSON ( r , & body ); err != nil {
Error ( w , http . StatusBadRequest , "invalid json" )
return
}
if err := s . Auth . ChangePassword ( r . Context (), uid , body . CurrentPassword , body . Password ); err != nil {
if errors . Is ( err , auth . ErrMustSetPassword ) {
Error ( w , http . StatusBadRequest , "set password first" )
return
}
if errors . Is ( err , auth . ErrInvalidCredentials ) {
Error ( w , http . StatusBadRequest , "current password is incorrect" )
return
}
ClientOrLog ( w , http . StatusBadRequest , "could not change password" , err , auth . ClientError )
return
}
s . putSessionVersion ( r . Context (), uid )
JSON ( w , http . StatusOK , map [ string ] string { "status" : "ok" })
}
func ( s * Server ) handleSelectCompany ( w http . ResponseWriter , r * http . Request ) {
uid , _ := UserIDFromContext ( r . Context ())
var body struct {
CompanyID string `json:"company_id"`
}
if err := DecodeJSON ( r , & body ); err != nil {
Error ( w , http . StatusBadRequest , "invalid json" )
return
}
cid , err := uuid . Parse ( body . CompanyID )
if err != nil {
Error ( w , http . StatusBadRequest , "invalid company_id" )
return
}
2026-08-14 00:06:43 +02:00
_ , memErr := s . Auth . EnsureMembership ( r . Context (), uid , cid )
staffOK := s . staffMayActAsCompany ( r . Context (), uid )
if memErr != nil {
if !( errors . Is ( memErr , auth . ErrNotCompanyMember ) && staffOK ) {
Error ( w , http . StatusForbidden , "forbidden" )
return
}
if _ , cerr := s . Auth . CompanyByID ( r . Context (), cid ); cerr != nil {
if errors . Is ( cerr , auth . ErrCompanyNotFound ) {
Error ( w , http . StatusNotFound , "company not found" )
return
}
Error ( w , http . StatusInternalServerError , "company lookup failed" )
return
}
// First hop into a foreign tenant: remember home company for revert.
if strings . TrimSpace ( s . Sessions . GetString ( r . Context (), auth . SessionStaffHomeCompanyKey )) == "" {
if home := s . resolveStaffHomeCompany ( r . Context (), uid ); home != uuid . Nil {
s . Sessions . Put ( r . Context (), auth . SessionStaffHomeCompanyKey , home . String ())
}
}
} else {
// Back on a membership company — clear act-as home.
s . Sessions . Remove ( r . Context (), auth . SessionStaffHomeCompanyKey )
2026-08-09 22:47:43 +02:00
}
s . Sessions . Put ( r . Context (), auth . SessionCompanyIDKey , cid . String ())
JSON ( w , http . StatusOK , map [ string ] string { "company_id" : cid . String ()})
}
2026-08-14 00:06:43 +02:00
// resolveStaffHomeCompany picks the company to restore on revert: current session
// company when the admin is a member, else their first membership company.
func ( s * Server ) resolveStaffHomeCompany ( ctx context . Context , userID uuid . UUID ) uuid . UUID {
if cur := strings . TrimSpace ( s . Sessions . GetString ( ctx , auth . SessionCompanyIDKey )); cur != "" {
if cid , err := uuid . Parse ( cur ); err == nil {
if _ , err := s . Auth . EnsureMembership ( ctx , userID , cid ); err == nil {
return cid
}
}
}
companies , err := s . Auth . ListUserCompanies ( ctx , userID )
if err != nil || len ( companies ) == 0 {
return uuid . Nil
}
return companies [ 0 ]. ID
}