fix
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/mail"
|
||||
)
|
||||
|
||||
func TestAcceptInviteURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := mail.AcceptInviteURL("http://localhost:28472/", "abc123")
|
||||
want := "http://localhost:28472/accept-invite?token=abc123"
|
||||
if got != want {
|
||||
t.Fatalf("AcceptInviteURL = %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
||||
@@ -12,6 +15,10 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// syncA1MaxBodyBytes caps JSON or multipart Sync A1 payloads (dump + form fields).
|
||||
// Slightly above MaxWPCategorySQLBytes for multipart overhead / base64 inflation.
|
||||
const syncA1MaxBodyBytes = catalog.MaxWPCategorySQLBytes + (2 << 20)
|
||||
|
||||
// handleAdminSyncCompanyA1 runs dump category backfill (when dump is on the API
|
||||
// host filesystem) plus FixCompanyCatalog hygiene. Does not wipe/reimport.
|
||||
//
|
||||
@@ -20,9 +27,13 @@ import (
|
||||
// POST /api/admin/companies/{id}/sync-a1
|
||||
// POST /api/admin/companies/{id}/fix-catalog (compat alias)
|
||||
//
|
||||
// Body: confirm=true required; backfill_categories (default true);
|
||||
// Body (JSON): confirm=true required; backfill_categories (default true);
|
||||
// reprocess_sample_limit (default 25, max 200); mysql_dump optional path;
|
||||
// skip_dump_backfill (default false).
|
||||
// skip_dump_backfill (default false); wp_product_categories_sql_b64 optional
|
||||
// base64 of wp_product_categories.sql (primary for category prompts).
|
||||
//
|
||||
// Body (multipart/form-data): same fields as form values; file field
|
||||
// wp_product_categories or wp_categories_sql for the SQL dump upload.
|
||||
//
|
||||
// Flash (UI): result → flash.admin.syncA1Success.
|
||||
func (s *Server) handleAdminSyncCompanyA1(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -37,18 +48,14 @@ func (s *Server) handleAdminSyncCompanyA1(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Confirm bool `json:"confirm"`
|
||||
BackfillCategories *bool `json:"backfill_categories"`
|
||||
ReprocessSampleLimit *int `json:"reprocess_sample_limit"`
|
||||
MySQLDump string `json:"mysql_dump"`
|
||||
SkipDumpBackfill bool `json:"skip_dump_backfill"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
r.Body = http.MaxBytesReader(w, r.Body, syncA1MaxBodyBytes)
|
||||
|
||||
parsed, err := parseSyncA1Request(r)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if !body.Confirm {
|
||||
if !parsed.Confirm {
|
||||
Error(w, http.StatusBadRequest, "confirm must be true")
|
||||
return
|
||||
}
|
||||
@@ -67,12 +74,12 @@ func (s *Server) handleAdminSyncCompanyA1(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
|
||||
backfill := true
|
||||
if body.BackfillCategories != nil {
|
||||
backfill = *body.BackfillCategories
|
||||
if parsed.BackfillCategories != nil {
|
||||
backfill = *parsed.BackfillCategories
|
||||
}
|
||||
sampleLimit := 25
|
||||
if body.ReprocessSampleLimit != nil {
|
||||
sampleLimit = *body.ReprocessSampleLimit
|
||||
if parsed.ReprocessSampleLimit != nil {
|
||||
sampleLimit = *parsed.ReprocessSampleLimit
|
||||
}
|
||||
if sampleLimit < 0 {
|
||||
sampleLimit = 0
|
||||
@@ -92,9 +99,10 @@ func (s *Server) handleAdminSyncCompanyA1(w http.ResponseWriter, r *http.Request
|
||||
BackfillCategories: backfill,
|
||||
ReprocessSampleLimit: sampleLimit,
|
||||
AIPrompts: s.AIPrompts,
|
||||
WPCategoriesSQL: parsed.WPCategoriesSQL,
|
||||
},
|
||||
MySQLDumpPath: strings.TrimSpace(body.MySQLDump),
|
||||
SkipDumpBackfill: body.SkipDumpBackfill,
|
||||
MySQLDumpPath: strings.TrimSpace(parsed.MySQLDump),
|
||||
SkipDumpBackfill: parsed.SkipDumpBackfill,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
@@ -104,7 +112,12 @@ func (s *Server) handleAdminSyncCompanyA1(w http.ResponseWriter, r *http.Request
|
||||
|
||||
note := "Synced in place (dump categories when available + hygiene); reprocess recommended products manually (no mass reprocess)."
|
||||
if !result.DumpFound {
|
||||
note = "Hygiene completed without dump backfill. Place descrybe_new.sql on the API host (scripts/seed/ or SEED_A1_MYSQL_DUMP) for mapped category coverage from the legacy dump."
|
||||
note = "Hygiene completed without product-dump category backfill. Place descrybe_new.sql on the API host (scripts/seed/ or SEED_A1_MYSQL_DUMP) for mapped category coverage from the legacy product dump."
|
||||
}
|
||||
if len(parsed.WPCategoriesSQL) > 0 {
|
||||
note = "Applied uploaded wp_product_categories.sql (Title/Description/Meta/Attributes sections) + catalog hygiene. Reprocess recommended products manually (no mass reprocess)."
|
||||
} else if strings.TrimSpace(result.WPCategoriesPath) == "" {
|
||||
note += " Upload wp_product_categories.sql on Sync A1 for force-applied category prompts when the dump is not on the API host."
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
@@ -118,3 +131,135 @@ func (s *Server) handleAdminSyncCompanyA1(w http.ResponseWriter, r *http.Request
|
||||
func (s *Server) handleAdminFixCompanyCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleAdminSyncCompanyA1(w, r)
|
||||
}
|
||||
|
||||
type syncA1Request struct {
|
||||
Confirm bool
|
||||
BackfillCategories *bool
|
||||
ReprocessSampleLimit *int
|
||||
MySQLDump string
|
||||
SkipDumpBackfill bool
|
||||
WPCategoriesSQL []byte
|
||||
}
|
||||
|
||||
func parseSyncA1Request(r *http.Request) (syncA1Request, error) {
|
||||
ct := strings.ToLower(strings.TrimSpace(r.Header.Get("Content-Type")))
|
||||
if strings.HasPrefix(ct, "multipart/form-data") {
|
||||
return parseSyncA1Multipart(r)
|
||||
}
|
||||
return parseSyncA1JSON(r)
|
||||
}
|
||||
|
||||
func parseSyncA1JSON(r *http.Request) (syncA1Request, error) {
|
||||
var body struct {
|
||||
Confirm bool `json:"confirm"`
|
||||
BackfillCategories *bool `json:"backfill_categories"`
|
||||
ReprocessSampleLimit *int `json:"reprocess_sample_limit"`
|
||||
MySQLDump string `json:"mysql_dump"`
|
||||
SkipDumpBackfill bool `json:"skip_dump_backfill"`
|
||||
WPProductCategoriesB64 string `json:"wp_product_categories_sql_b64"`
|
||||
WPProductCategoriesPath string `json:"wp_product_categories"` // path-only; ignored for apply (upload required in prod)
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
return syncA1Request{}, errInvalidJSON
|
||||
}
|
||||
out := syncA1Request{
|
||||
Confirm: body.Confirm,
|
||||
BackfillCategories: body.BackfillCategories,
|
||||
ReprocessSampleLimit: body.ReprocessSampleLimit,
|
||||
MySQLDump: body.MySQLDump,
|
||||
SkipDumpBackfill: body.SkipDumpBackfill,
|
||||
}
|
||||
_ = body.WPProductCategoriesPath // path paste is not enough for prod — ignore
|
||||
b64 := strings.TrimSpace(body.WPProductCategoriesB64)
|
||||
if b64 == "" {
|
||||
return out, nil
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
// Allow URL-safe / raw without padding for convenience.
|
||||
raw, err = base64.RawStdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
raw, err = base64.URLEncoding.DecodeString(b64)
|
||||
}
|
||||
if err != nil {
|
||||
return syncA1Request{}, errWPCategoriesB64
|
||||
}
|
||||
}
|
||||
if len(raw) > catalog.MaxWPCategorySQLBytes {
|
||||
return syncA1Request{}, errWPCategoriesTooLarge
|
||||
}
|
||||
out.WPCategoriesSQL = raw
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseSyncA1Multipart(r *http.Request) (syncA1Request, error) {
|
||||
if err := r.ParseMultipartForm(syncA1MaxBodyBytes); err != nil {
|
||||
return syncA1Request{}, errInvalidMultipart
|
||||
}
|
||||
out := syncA1Request{
|
||||
Confirm: parseFormBool(r.FormValue("confirm")),
|
||||
MySQLDump: strings.TrimSpace(r.FormValue("mysql_dump")),
|
||||
SkipDumpBackfill: parseFormBool(r.FormValue("skip_dump_backfill")),
|
||||
}
|
||||
if v := strings.TrimSpace(r.FormValue("backfill_categories")); v != "" {
|
||||
b := parseFormBool(v)
|
||||
out.BackfillCategories = &b
|
||||
}
|
||||
if v := strings.TrimSpace(r.FormValue("reprocess_sample_limit")); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return syncA1Request{}, errInvalidSampleLimit
|
||||
}
|
||||
out.ReprocessSampleLimit = &n
|
||||
}
|
||||
|
||||
file, _, err := r.FormFile("wp_product_categories")
|
||||
if err != nil {
|
||||
file, _, err = r.FormFile("wp_categories_sql")
|
||||
}
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
raw, readErr := io.ReadAll(io.LimitReader(file, int64(catalog.MaxWPCategorySQLBytes)+1))
|
||||
if readErr != nil {
|
||||
return syncA1Request{}, errWPCategoriesRead
|
||||
}
|
||||
if len(raw) > catalog.MaxWPCategorySQLBytes {
|
||||
return syncA1Request{}, errWPCategoriesTooLarge
|
||||
}
|
||||
out.WPCategoriesSQL = raw
|
||||
}
|
||||
|
||||
if b64 := strings.TrimSpace(r.FormValue("wp_product_categories_sql_b64")); b64 != "" && len(out.WPCategoriesSQL) == 0 {
|
||||
raw, decErr := base64.StdEncoding.DecodeString(b64)
|
||||
if decErr != nil {
|
||||
return syncA1Request{}, errWPCategoriesB64
|
||||
}
|
||||
if len(raw) > catalog.MaxWPCategorySQLBytes {
|
||||
return syncA1Request{}, errWPCategoriesTooLarge
|
||||
}
|
||||
out.WPCategoriesSQL = raw
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseFormBool(v string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type syncA1ParseError string
|
||||
|
||||
func (e syncA1ParseError) Error() string { return string(e) }
|
||||
|
||||
var (
|
||||
errInvalidJSON = syncA1ParseError("invalid json")
|
||||
errInvalidMultipart = syncA1ParseError("invalid multipart form")
|
||||
errInvalidSampleLimit = syncA1ParseError("invalid reprocess_sample_limit")
|
||||
errWPCategoriesB64 = syncA1ParseError("invalid wp_product_categories_sql_b64")
|
||||
errWPCategoriesTooLarge = syncA1ParseError("wp_product_categories.sql too large")
|
||||
errWPCategoriesRead = syncA1ParseError("could not read wp_product_categories upload")
|
||||
)
|
||||
|
||||
@@ -216,6 +216,7 @@ func (s *Server) handleAdminSendSetPasswordEmails(w http.ResponseWriter, r *http
|
||||
skippedRateLimited := 0
|
||||
skippedSend := 0
|
||||
var singleToken string
|
||||
var singleMode string
|
||||
singleUser := body.UserID != nil
|
||||
|
||||
for _, uid := range targets {
|
||||
@@ -251,6 +252,11 @@ func (s *Server) handleAdminSendSetPasswordEmails(w http.ResponseWriter, r *http
|
||||
if err := s.Mail.Send(msg); err != nil {
|
||||
log.Printf("admin set-password send failed user_id=%s", uid)
|
||||
skippedSend++
|
||||
if singleUser {
|
||||
// Still return the one-time link so admins can share it while impersonating / offline SMTP.
|
||||
singleToken = token
|
||||
singleMode = mode
|
||||
}
|
||||
continue
|
||||
}
|
||||
if smtpOn {
|
||||
@@ -258,6 +264,7 @@ func (s *Server) handleAdminSendSetPasswordEmails(w http.ResponseWriter, r *http
|
||||
} else if singleUser {
|
||||
// Share token only for single-user reissue when SMTP is off (no email in response).
|
||||
singleToken = token
|
||||
singleMode = mode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,6 +282,11 @@ func (s *Server) handleAdminSendSetPasswordEmails(w http.ResponseWriter, r *http
|
||||
}
|
||||
if singleToken != "" {
|
||||
resp["token"] = singleToken
|
||||
if singleMode == "hmac" {
|
||||
resp["accept_url"] = mail.SetPasswordURL(s.Config.WebOrigin, singleToken)
|
||||
} else {
|
||||
resp["accept_url"] = mail.AcceptInviteURL(s.Config.WebOrigin, singleToken)
|
||||
}
|
||||
}
|
||||
JSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
|
||||
)
|
||||
|
||||
func TestParseSyncA1JSON_Base64Upload(t *testing.T) {
|
||||
t.Parallel()
|
||||
sql := "INSERT INTO `wp_product_categories` (`Name`, `Prompt`) VALUES\n('A','GPT predloga:\\n<name>{x}</name><metaDescription>{y}</metaDescription>');\n"
|
||||
b64 := base64.StdEncoding.EncodeToString([]byte(sql))
|
||||
body := `{"confirm":true,"wp_product_categories_sql_b64":"` + b64 + `"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/sync", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
parsed, err := parseSyncA1JSON(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !parsed.Confirm {
|
||||
t.Fatal("confirm")
|
||||
}
|
||||
if string(parsed.WPCategoriesSQL) != sql {
|
||||
t.Fatalf("sql mismatch len=%d", len(parsed.WPCategoriesSQL))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSyncA1JSON_RejectsOversizedBase64(t *testing.T) {
|
||||
t.Parallel()
|
||||
raw := make([]byte, catalog.MaxWPCategorySQLBytes+1)
|
||||
b64 := base64.StdEncoding.EncodeToString(raw)
|
||||
body := `{"confirm":true,"wp_product_categories_sql_b64":"` + b64 + `"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/sync", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
_, err := parseSyncA1JSON(req)
|
||||
if err == nil {
|
||||
t.Fatal("expected too-large error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSyncA1Multipart_FileUpload(t *testing.T) {
|
||||
t.Parallel()
|
||||
sql := "INSERT INTO `wp_product_categories` (`Name`, `Prompt`) VALUES\n('B','GPT predloga:\\n<name>{x}</name><metaDescription>{y}</metaDescription>');\n"
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
if err := w.WriteField("confirm", "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
part, err := w.CreateFormFile("wp_product_categories", "wp_product_categories.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := part.Write([]byte(sql)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ct := w.FormDataContentType()
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/sync", bytes.NewReader(buf.Bytes()))
|
||||
req.Header.Set("Content-Type", ct)
|
||||
parsed, err := parseSyncA1Multipart(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !parsed.Confirm {
|
||||
t.Fatal("confirm")
|
||||
}
|
||||
if string(parsed.WPCategoriesSQL) != sql {
|
||||
t.Fatalf("sql mismatch len=%d", len(parsed.WPCategoriesSQL))
|
||||
}
|
||||
}
|
||||
@@ -347,9 +347,13 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if m, err := s.Auth.EnsureMembership(r.Context(), uid, cid); err == nil {
|
||||
out["membership"] = map[string]string{"role": m.Role, "status": m.Status}
|
||||
if isOwner, oerr := s.Auth.IsCompanyOwner(r.Context(), cid, uid); oerr == nil {
|
||||
out["is_owner"] = isOwner
|
||||
}
|
||||
} else if staffTenantSwitch && errors.Is(err, auth.ErrNotCompanyMember) {
|
||||
staffOverride = true
|
||||
out["membership"] = map[string]any{"role": "admin", "status": "active", "staff_override": true}
|
||||
out["is_owner"] = false
|
||||
}
|
||||
}
|
||||
if staffTenantSwitch && homeStr != "" {
|
||||
|
||||
@@ -20,20 +20,25 @@ func (s *Server) handleGetCompany(w http.ResponseWriter, r *http.Request) {
|
||||
name, language string
|
||||
merge bool
|
||||
contentLangs []string
|
||||
ownerUserID *uuid.UUID
|
||||
)
|
||||
err := s.Pool.QueryRow(r.Context(), `
|
||||
SELECT id, name, language, merge_products_by_gtin, COALESCE(content_languages, '{}')
|
||||
SELECT id, name, language, merge_products_by_gtin, COALESCE(content_languages, '{}'), owner_user_id
|
||||
FROM companies WHERE id = $1`, cid).
|
||||
Scan(&id, &name, &language, &merge, &contentLangs)
|
||||
Scan(&id, &name, &language, &merge, &contentLangs, &ownerUserID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "company not found")
|
||||
return
|
||||
}
|
||||
parsed, _ := company.ParseContentLanguages(contentLangs, language)
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
out := map[string]any{
|
||||
"id": id, "name": name, "language": language,
|
||||
"content_languages": parsed, "merge_products_by_gtin": merge,
|
||||
})
|
||||
}
|
||||
if ownerUserID != nil {
|
||||
out["owner_user_id"] = *ownerUserID
|
||||
}
|
||||
JSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateCompany(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -150,6 +155,8 @@ func (s *Server) handlePutCompanySettings(w http.ResponseWriter, r *http.Request
|
||||
func (s *Server) handleListTeam(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
limit, offset := ParseLimitOffset(r)
|
||||
var ownerUserID *uuid.UUID
|
||||
_ = s.Pool.QueryRow(r.Context(), `SELECT owner_user_id FROM companies WHERE id = $1`, cid).Scan(&ownerUserID)
|
||||
var total int64
|
||||
if err := s.Pool.QueryRow(r.Context(), `
|
||||
SELECT count(*) FROM memberships m WHERE m.company_id = $1 AND m.status = 'active'`, cid).Scan(&total); err != nil {
|
||||
@@ -166,12 +173,13 @@ func (s *Server) handleListTeam(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer rows.Close()
|
||||
type member struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
Email string `json:"email"`
|
||||
Name *string `json:"name"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
Email string `json:"email"`
|
||||
Name *string `json:"name"`
|
||||
IsOwner bool `json:"is_owner"`
|
||||
}
|
||||
out := make([]member, 0)
|
||||
for rows.Next() {
|
||||
@@ -180,9 +188,14 @@ func (s *Server) handleListTeam(w http.ResponseWriter, r *http.Request) {
|
||||
Error(w, http.StatusInternalServerError, "scan failed")
|
||||
return
|
||||
}
|
||||
m.IsOwner = ownerUserID != nil && *ownerUserID == m.UserID
|
||||
out = append(out, m)
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"members": out, "total": total, "limit": limit, "offset": offset})
|
||||
resp := map[string]any{"members": out, "total": total, "limit": limit, "offset": offset}
|
||||
if ownerUserID != nil {
|
||||
resp["owner_user_id"] = *ownerUserID
|
||||
}
|
||||
JSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateInvite(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -222,6 +235,7 @@ func (s *Server) handleCreateInvite(w http.ResponseWriter, r *http.Request) {
|
||||
// Token returned when email was not delivered so operators can share the accept link.
|
||||
if includeToken {
|
||||
resp["token"] = token
|
||||
resp["accept_url"] = mail.AcceptInviteURL(s.Config.WebOrigin, token)
|
||||
}
|
||||
JSON(w, http.StatusCreated, resp)
|
||||
}
|
||||
@@ -243,6 +257,13 @@ func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||
Error(w, http.StatusBadRequest, "invalid user id")
|
||||
return
|
||||
}
|
||||
if ownerID, hasOwner, oerr := s.Auth.CompanyOwnerID(r.Context(), cid); oerr != nil {
|
||||
Error(w, http.StatusInternalServerError, "lookup failed")
|
||||
return
|
||||
} else if hasOwner && ownerID == userID {
|
||||
Error(w, http.StatusConflict, auth.ErrCannotRemoveOwner.Error())
|
||||
return
|
||||
}
|
||||
var currentRole, status string
|
||||
err = s.Pool.QueryRow(r.Context(), `
|
||||
SELECT role, status FROM memberships
|
||||
@@ -361,3 +382,49 @@ func (s *Server) handleUpdateMemberRole(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"status": "ok", "role": newRole, "user_id": userID})
|
||||
}
|
||||
|
||||
func (s *Server) handleTransferOwnership(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
uid, ok := UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
platformAdmin, err := s.checkPlatformAdmin(r.Context(), uid)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "authorization check failed")
|
||||
return
|
||||
}
|
||||
if !platformAdmin {
|
||||
isOwner, oerr := s.Auth.IsCompanyOwner(r.Context(), cid, uid)
|
||||
if oerr != nil {
|
||||
Error(w, http.StatusInternalServerError, "authorization check failed")
|
||||
return
|
||||
}
|
||||
if !isOwner {
|
||||
Error(w, http.StatusForbidden, auth.ErrNotCompanyOwner.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
var body struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil || body.UserID == uuid.Nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if err := s.Auth.TransferOwnership(r.Context(), cid, body.UserID); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, auth.ErrTransferSelf):
|
||||
JSON(w, http.StatusOK, map[string]any{"status": "ok", "owner_user_id": body.UserID})
|
||||
case errors.Is(err, auth.ErrOwnerRequired), errors.Is(err, auth.ErrNotCompanyMember):
|
||||
Error(w, http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, auth.ErrCompanyNotFound):
|
||||
Error(w, http.StatusNotFound, err.Error())
|
||||
default:
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not transfer ownership", err, auth.ClientError)
|
||||
}
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"status": "ok", "owner_user_id": body.UserID})
|
||||
}
|
||||
|
||||
@@ -89,6 +89,49 @@ func (s *Server) allowCompanyAdminOrPlatform(w http.ResponseWriter, r *http.Requ
|
||||
return false
|
||||
}
|
||||
|
||||
// allowCompanyOwnerOrPlatform allows the company billing owner or a platform admin.
|
||||
// When owner_user_id is unset (pre-backfill), falls back to company admin so billing is not locked out.
|
||||
func (s *Server) allowCompanyOwnerOrPlatform(w http.ResponseWriter, r *http.Request) bool {
|
||||
uid, ok := UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return false
|
||||
}
|
||||
isAdmin, err := s.checkPlatformAdmin(r.Context(), uid)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "authorization check failed")
|
||||
return false
|
||||
}
|
||||
if isAdmin {
|
||||
return true
|
||||
}
|
||||
cid, ok := CompanyIDFromContext(r.Context())
|
||||
if !ok {
|
||||
Error(w, http.StatusForbidden, "company required")
|
||||
return false
|
||||
}
|
||||
if s.Auth != nil {
|
||||
ownerID, hasOwner, err := s.Auth.CompanyOwnerID(r.Context(), cid)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "authorization check failed")
|
||||
return false
|
||||
}
|
||||
if hasOwner {
|
||||
if ownerID == uid {
|
||||
return true
|
||||
}
|
||||
Error(w, http.StatusForbidden, "company owner required")
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Legacy fallback before owner backfill, or unit tests without Auth wired.
|
||||
if CompanyAdminAllowed(r.Context()) {
|
||||
return true
|
||||
}
|
||||
Error(w, http.StatusForbidden, "company owner required")
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) RequireSession(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
uidStr := s.Sessions.GetString(r.Context(), auth.SessionUserIDKey)
|
||||
|
||||
@@ -447,6 +447,7 @@ func (s *Server) Router() http.Handler {
|
||||
r.Post("/team/invites", s.handleCreateInvite)
|
||||
r.Get("/team/invites", s.handleListInvites)
|
||||
r.Delete("/team/invites/{inviteID}", s.handleRevokeInvite)
|
||||
r.Post("/team/transfer-ownership", s.handleTransferOwnership)
|
||||
r.Patch("/team/{userID}", s.handleUpdateMemberRole)
|
||||
r.Delete("/team/{userID}", s.handleRemoveMember)
|
||||
|
||||
|
||||
@@ -40,9 +40,7 @@ func (s *Server) handleStripeStatus(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleStripeCheckout(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
uid, _ := UserIDFromContext(r.Context())
|
||||
role, _ := RoleFromContext(r.Context())
|
||||
if role != "admin" {
|
||||
Error(w, http.StatusForbidden, "admin required")
|
||||
if !s.allowCompanyOwnerOrPlatform(w, r) {
|
||||
return
|
||||
}
|
||||
platformAdmin, err := s.checkPlatformAdmin(r.Context(), uid)
|
||||
@@ -73,9 +71,7 @@ func (s *Server) handleStripeCheckout(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleStripePortal(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
role, _ := RoleFromContext(r.Context())
|
||||
if role != "admin" {
|
||||
Error(w, http.StatusForbidden, "admin required")
|
||||
if !s.allowCompanyOwnerOrPlatform(w, r) {
|
||||
return
|
||||
}
|
||||
res, err := s.stripeSvc().CreatePortalSession(r.Context(), cid)
|
||||
|
||||
Reference in New Issue
Block a user