Compare commits
9
Commits
310b3ab03f
...
7b077905e2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b077905e2 | ||
|
|
2d12669f9b | ||
|
|
a05a74cf4d | ||
|
|
14b855fa9f | ||
|
|
0f5ad1d836 | ||
|
|
cca0ffbeae | ||
|
|
0c663945ee | ||
|
|
b37ed967b6 | ||
|
|
a7b9af4422 |
@@ -33,6 +33,7 @@ jobs:
|
||||
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/web:latest"
|
||||
docker build \
|
||||
--build-arg NEXT_PUBLIC_API_URL="${{ vars.API_URL }}" \
|
||||
--build-arg NEXT_PUBLIC_HQ_URL="${{ vars.HQ_URL }}" \
|
||||
-t "$IMAGE" \
|
||||
-f web/Dockerfile web/
|
||||
docker push "$IMAGE"
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/mrhid6/vantage/admin/internal/auth"
|
||||
"github.com/mrhid6/vantage/admin/internal/config"
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
"github.com/mrhid6/vantage/admin/internal/hqsync"
|
||||
"github.com/mrhid6/vantage/admin/internal/inject"
|
||||
"github.com/mrhid6/vantage/admin/internal/licensing"
|
||||
"github.com/mrhid6/vantage/admin/internal/lifecycle"
|
||||
@@ -67,11 +68,16 @@ func main() {
|
||||
idxCancel()
|
||||
log.Fatalf("plan seed: %v", err)
|
||||
}
|
||||
if err := models.Backfill(idxCtx); err != nil {
|
||||
idxCancel()
|
||||
log.Fatalf("backfill: %v", err)
|
||||
}
|
||||
idxCancel()
|
||||
|
||||
reconcileCtx, stopReconcile := context.WithCancel(context.Background())
|
||||
defer stopReconcile()
|
||||
inject.StartReconciler(reconcileCtx)
|
||||
hqsync.Start(reconcileCtx)
|
||||
|
||||
lifecycle.SetPortalURL(cfg.PublicURL)
|
||||
lifecycle.Start(reconcileCtx, cfg.ReapAfter)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/vantage/admin/internal/audit"
|
||||
"github.com/mrhid6/vantage/admin/internal/auth"
|
||||
"github.com/mrhid6/vantage/admin/internal/cloudprov"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
"github.com/mrhid6/vantage/admin/internal/mail"
|
||||
"github.com/mrhid6/vantage/admin/internal/models"
|
||||
"github.com/mrhid6/vantage/shared/license"
|
||||
sharedmodels "github.com/mrhid6/vantage/shared/models"
|
||||
"github.com/mrhid6/vantage/shared/provision"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
@@ -55,11 +57,15 @@ func getMe(c *gin.Context) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "not signed in"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"kind": s.Kind,
|
||||
"email": s.Email,
|
||||
"account_id": s.AccountID,
|
||||
})
|
||||
out := gin.H{"kind": s.Kind, "email": s.Email, "account_id": s.AccountID}
|
||||
if s.Kind == auth.KindCustomer {
|
||||
var u models.CustomerUser
|
||||
if err := db.Admin("customer_users").FindOne(c.Request.Context(),
|
||||
bson.M{"user_id": s.UserID}).Decode(&u); err == nil {
|
||||
out["account_role"] = u.AccountRole
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func getAccount(c *gin.Context) {
|
||||
@@ -277,6 +283,25 @@ func createInstance(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Record the owner's membership. Best-effort: the projected user already
|
||||
// exists and is what actually grants access, so a missing row here costs a
|
||||
// line in the members panel, not access — and the boot backfill rebuilds it.
|
||||
ownerID, err := cloudprov.OwnerUserID(ctx, inst.InstanceID)
|
||||
if err != nil {
|
||||
log.Printf("createInstance: owner lookup for %s: %v", inst.InstanceID, err)
|
||||
} else if _, err := db.Admin("instance_members").InsertOne(ctx, models.InstanceMember{
|
||||
MemberID: uuid.NewString(),
|
||||
AccountID: s.AccountID,
|
||||
InstanceID: inst.InstanceID,
|
||||
CustomerUserID: cu.UserID,
|
||||
ControlUserID: ownerID,
|
||||
Role: sharedmodels.RoleOwner,
|
||||
Email: cu.Email,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}); err != nil {
|
||||
log.Printf("createInstance: record owner membership for %s: %v", inst.InstanceID, err)
|
||||
}
|
||||
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: s.Email, Action: "instance.created", AccountID: s.AccountID,
|
||||
Target: inst.InstanceID, Detail: "slug=" + inst.Slug, IP: c.ClientIP()})
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/vantage/admin/internal/audit"
|
||||
"github.com/mrhid6/vantage/admin/internal/auth"
|
||||
"github.com/mrhid6/vantage/admin/internal/cloudprov"
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
"github.com/mrhid6/vantage/admin/internal/models"
|
||||
"github.com/mrhid6/vantage/shared/license"
|
||||
sharedmodels "github.com/mrhid6/vantage/shared/models"
|
||||
"github.com/mrhid6/vantage/shared/provision"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// selfHostedRefusal is the one message every membership endpoint gives for a
|
||||
// self-hosted instance. Their users live in their own deployment, which we
|
||||
// cannot see and must not write to.
|
||||
const selfHostedRefusal = "this install manages its own users; add them in Settings → Instance inside your Vantage install"
|
||||
|
||||
func listInstanceMembers(c *gin.Context) {
|
||||
inst, ok := ownedInstance(c, c.Param("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
cur, err := db.Admin("instance_members").Find(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
members := []models.InstanceMember{}
|
||||
if err := cur.All(ctx, &members); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, members)
|
||||
}
|
||||
|
||||
// grantInstanceMember projects an account person into a cloud instance.
|
||||
func grantInstanceMember(c *gin.Context) {
|
||||
inst, ok := ownedInstance(c, c.Param("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if inst.Deployment != license.DeploymentCloud {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": selfHostedRefusal})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
UserID string `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.UserID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"})
|
||||
return
|
||||
}
|
||||
if body.Role == "" {
|
||||
body.Role = sharedmodels.RoleMember
|
||||
}
|
||||
if !sharedmodels.ValidRole(body.Role) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"})
|
||||
return
|
||||
}
|
||||
|
||||
target, ok := accountUser(c, body.UserID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if target.VerifiedAt == nil || target.PasswordHash == "" {
|
||||
// The projection copies a hash. An unverified invitee has no hash, so
|
||||
// the row would exist and be unusable — and an address nobody has
|
||||
// proven they control would hold a login inside a real instance.
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "they have not accepted their invitation yet"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
s := auth.Current(c)
|
||||
|
||||
u, err := cloudprov.GrantUser(ctx, inst.InstanceID, target.Email,
|
||||
target.PasswordHash, body.Role, target.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, provision.ErrEmailTaken) {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "that address already has a user inside this instance"})
|
||||
return
|
||||
}
|
||||
log.Printf("grant %s to %s: %v", target.Email, inst.InstanceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not grant access"})
|
||||
return
|
||||
}
|
||||
|
||||
m := models.InstanceMember{
|
||||
MemberID: uuid.NewString(),
|
||||
AccountID: inst.AccountID,
|
||||
InstanceID: inst.InstanceID,
|
||||
CustomerUserID: target.UserID,
|
||||
ControlUserID: u.UserID,
|
||||
Role: body.Role,
|
||||
Email: target.Email,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if _, err := db.Admin("instance_members").InsertOne(ctx, m); err != nil {
|
||||
// Unwind the projection: a control-plane login nobody on this side
|
||||
// records is a login nobody can revoke through the portal.
|
||||
if rErr := cloudprov.RevokeUser(ctx, inst.InstanceID, target.UserID); rErr != nil {
|
||||
log.Printf("grant: FAILED to unwind projection of %s in %s: %v",
|
||||
target.Email, inst.InstanceID, rErr)
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not grant access"})
|
||||
return
|
||||
}
|
||||
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: s.Email, Action: "instance_member.granted", AccountID: s.AccountID,
|
||||
Target: inst.InstanceID, Detail: target.Email + " role=" + body.Role, IP: c.ClientIP()})
|
||||
c.JSON(http.StatusCreated, m)
|
||||
}
|
||||
|
||||
// memberRow loads one membership on an instance the caller owns.
|
||||
func memberRow(c *gin.Context, instanceID, customerUserID string) (*models.InstanceMember, bool) {
|
||||
var m models.InstanceMember
|
||||
if err := db.Admin("instance_members").FindOne(c.Request.Context(), bson.M{
|
||||
"instance_id": instanceID,
|
||||
"customer_user_id": customerUserID,
|
||||
}).Decode(&m); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return nil, false
|
||||
}
|
||||
return &m, true
|
||||
}
|
||||
|
||||
func updateInstanceMemberRole(c *gin.Context) {
|
||||
inst, ok := ownedInstance(c, c.Param("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if inst.Deployment != license.DeploymentCloud {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": selfHostedRefusal})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || !sharedmodels.ValidRole(body.Role) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"})
|
||||
return
|
||||
}
|
||||
m, ok := memberRow(c, inst.InstanceID, c.Param("uid"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
if m.Role == sharedmodels.RoleOwner && body.Role != sharedmodels.RoleOwner {
|
||||
others, err := cloudprov.CountOtherOwners(ctx, inst.InstanceID, m.CustomerUserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if others == 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "this is the instance's last owner; make someone else an owner first"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := cloudprov.SetMemberRole(ctx, inst.InstanceID, m.CustomerUserID, body.Role); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not change their role"})
|
||||
return
|
||||
}
|
||||
if _, err := db.Admin("instance_members").UpdateOne(ctx,
|
||||
bson.M{"member_id": m.MemberID},
|
||||
bson.M{"$set": bson.M{"role": body.Role}}); err != nil {
|
||||
log.Printf("member role: control plane updated but member row %s did not: %v", m.MemberID, err)
|
||||
}
|
||||
|
||||
s := auth.Current(c)
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: s.Email, Action: "instance_member.role_changed", AccountID: s.AccountID,
|
||||
Target: inst.InstanceID, Detail: m.Email + " role=" + body.Role, IP: c.ClientIP()})
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func revokeInstanceMember(c *gin.Context) {
|
||||
inst, ok := ownedInstance(c, c.Param("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if inst.Deployment != license.DeploymentCloud {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": selfHostedRefusal})
|
||||
return
|
||||
}
|
||||
m, ok := memberRow(c, inst.InstanceID, c.Param("uid"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
if m.Role == sharedmodels.RoleOwner {
|
||||
others, err := cloudprov.CountOtherOwners(ctx, inst.InstanceID, m.CustomerUserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if others == 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "this is the instance's last owner; make someone else an owner first"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := cloudprov.RevokeUser(ctx, inst.InstanceID, m.CustomerUserID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not revoke access"})
|
||||
return
|
||||
}
|
||||
if _, err := db.Admin("instance_members").DeleteOne(ctx,
|
||||
bson.M{"member_id": m.MemberID}); err != nil {
|
||||
log.Printf("revoke: control-plane user deleted but member row %s remains: %v", m.MemberID, err)
|
||||
}
|
||||
|
||||
s := auth.Current(c)
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: s.Email, Action: "instance_member.revoked", AccountID: s.AccountID,
|
||||
Target: inst.InstanceID, Detail: m.Email, IP: c.ClientIP()})
|
||||
c.JSON(http.StatusOK, gin.H{"revoked": true})
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mrhid6/vantage/admin/internal/audit"
|
||||
"github.com/mrhid6/vantage/admin/internal/auth"
|
||||
"github.com/mrhid6/vantage/admin/internal/cloudprov"
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
"github.com/mrhid6/vantage/admin/internal/models"
|
||||
sharedmodels "github.com/mrhid6/vantage/shared/models"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// listAccountUsers returns the account's people, newest last.
|
||||
//
|
||||
// Any signed-in member may read this. Knowing who your colleagues are is not
|
||||
// privileged, and hiding it would make the members panel unusable for the
|
||||
// people it is meant to inform.
|
||||
func listAccountUsers(c *gin.Context) {
|
||||
s := auth.Current(c)
|
||||
ctx := c.Request.Context()
|
||||
cur, err := db.Admin("customer_users").Find(ctx, bson.M{"account_id": s.AccountID})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
users := []models.CustomerUser{}
|
||||
if err := cur.All(ctx, &users); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, users)
|
||||
}
|
||||
|
||||
// inviteAccountUser adds a person to the account.
|
||||
//
|
||||
// It never sets a password: see CreateInvitedUser. Only an owner may invite
|
||||
// another owner, mirroring the control plane's own rule that an admin cannot
|
||||
// mint someone with more power than themselves.
|
||||
func inviteAccountUser(c *gin.Context) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email is required"})
|
||||
return
|
||||
}
|
||||
email := strings.ToLower(strings.TrimSpace(body.Email))
|
||||
if email == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email is required"})
|
||||
return
|
||||
}
|
||||
if body.Role == "" {
|
||||
body.Role = models.AccountRoleMember
|
||||
}
|
||||
if !models.ValidAccountRole(body.Role) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"})
|
||||
return
|
||||
}
|
||||
me := auth.CurrentUser(c)
|
||||
if body.Role == models.AccountRoleOwner && me.AccountRole != models.AccountRoleOwner {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can invite another owner"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
s := auth.Current(c)
|
||||
|
||||
// customer_users.email is globally unique, so an address already in use
|
||||
// anywhere cannot be invited here. Say so plainly: unlike signup there is
|
||||
// nothing to conceal, because the inviter already knows this address.
|
||||
if n, _ := db.Admin("customer_users").CountDocuments(ctx, bson.M{"email": email}); n > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "that address already has a Vantage HQ account"})
|
||||
return
|
||||
}
|
||||
|
||||
var acct models.Account
|
||||
if err := db.Admin("accounts").FindOne(ctx,
|
||||
bson.M{"account_id": s.AccountID}).Decode(&acct); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not read your account"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := auth.CreateInvitedUser(ctx, s.AccountID, acct.Name, email, body.Role); err != nil {
|
||||
log.Printf("invite %s to %s: %v", email, s.AccountID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not send the invitation"})
|
||||
return
|
||||
}
|
||||
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: s.Email, Action: "account_user.invited", AccountID: s.AccountID,
|
||||
Target: email, Detail: "role=" + body.Role, IP: c.ClientIP()})
|
||||
c.JSON(http.StatusCreated, gin.H{"invited": true})
|
||||
}
|
||||
|
||||
// accountUser loads one person and confirms they are on the caller's account.
|
||||
func accountUser(c *gin.Context, userID string) (*models.CustomerUser, bool) {
|
||||
s := auth.Current(c)
|
||||
var u models.CustomerUser
|
||||
if err := db.Admin("customer_users").FindOne(c.Request.Context(),
|
||||
bson.M{"user_id": userID, "account_id": s.AccountID}).Decode(&u); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return nil, false
|
||||
}
|
||||
return &u, true
|
||||
}
|
||||
|
||||
// countOtherAccountOwners counts owners of an account other than one person.
|
||||
func countOtherAccountOwners(c *gin.Context, exceptUserID string) (int64, error) {
|
||||
s := auth.Current(c)
|
||||
return db.Admin("customer_users").CountDocuments(c.Request.Context(), bson.M{
|
||||
"account_id": s.AccountID,
|
||||
"account_role": models.AccountRoleOwner,
|
||||
"user_id": bson.M{"$ne": exceptUserID},
|
||||
})
|
||||
}
|
||||
|
||||
func updateAccountUserRole(c *gin.Context) {
|
||||
var body struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || !models.ValidAccountRole(body.Role) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"})
|
||||
return
|
||||
}
|
||||
target, ok := accountUser(c, c.Param("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
me := auth.CurrentUser(c)
|
||||
if target.UserID == me.UserID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "you cannot change your own role"})
|
||||
return
|
||||
}
|
||||
if (body.Role == models.AccountRoleOwner || target.AccountRole == models.AccountRoleOwner) &&
|
||||
me.AccountRole != models.AccountRoleOwner {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can change owner roles"})
|
||||
return
|
||||
}
|
||||
if target.AccountRole == models.AccountRoleOwner && body.Role != models.AccountRoleOwner {
|
||||
others, err := countOtherAccountOwners(c, target.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if others == 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "this is the account's last owner; promote someone else first"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
if _, err := db.Admin("customer_users").UpdateOne(ctx,
|
||||
bson.M{"user_id": target.UserID},
|
||||
bson.M{"$set": bson.M{"account_role": body.Role}}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
s := auth.Current(c)
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: s.Email, Action: "account_user.role_changed", AccountID: s.AccountID,
|
||||
Target: target.Email, Detail: "role=" + body.Role, IP: c.ClientIP()})
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// deleteAccountUser removes a person and every instance they hold.
|
||||
//
|
||||
// Grants go first, and the whole request is refused if any of them would strand
|
||||
// an instance with no owner. Removing the person but leaving their projected
|
||||
// rows behind would leave working logins for someone the account has removed —
|
||||
// the exact failure this endpoint exists to prevent.
|
||||
func deleteAccountUser(c *gin.Context) {
|
||||
target, ok := accountUser(c, c.Param("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
me := auth.CurrentUser(c)
|
||||
if target.UserID == me.UserID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "you cannot remove your own account"})
|
||||
return
|
||||
}
|
||||
if target.AccountRole == models.AccountRoleOwner && me.AccountRole != models.AccountRoleOwner {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can remove another owner"})
|
||||
return
|
||||
}
|
||||
if target.AccountRole == models.AccountRoleOwner {
|
||||
others, err := countOtherAccountOwners(c, target.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if others == 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "this is the account's last owner; promote someone else first"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
s := auth.Current(c)
|
||||
|
||||
cur, err := db.Admin("instance_members").Find(ctx,
|
||||
bson.M{"customer_user_id": target.UserID})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
members := []models.InstanceMember{}
|
||||
if err := cur.All(ctx, &members); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Check every instance BEFORE deleting anything, so a refusal leaves the
|
||||
// person exactly as they were rather than half-revoked.
|
||||
for _, m := range members {
|
||||
if m.Role != sharedmodels.RoleOwner {
|
||||
continue
|
||||
}
|
||||
others, err := cloudprov.CountOtherOwners(ctx, m.InstanceID, target.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if others == 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "they are the last owner of an instance; give someone else that instance's owner role first"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range members {
|
||||
if err := cloudprov.RevokeUser(ctx, m.InstanceID, target.UserID); err != nil {
|
||||
log.Printf("deleteAccountUser: revoke %s from %s: %v", target.Email, m.InstanceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "could not remove their instance access; nothing was deleted"})
|
||||
return
|
||||
}
|
||||
if _, err := db.Admin("instance_members").DeleteOne(ctx,
|
||||
bson.M{"member_id": m.MemberID}); err != nil {
|
||||
log.Printf("deleteAccountUser: drop member row %s: %v", m.MemberID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := db.Admin("customer_users").DeleteOne(ctx,
|
||||
bson.M{"user_id": target.UserID}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: s.Email, Action: "account_user.removed", AccountID: s.AccountID,
|
||||
Target: target.Email, Detail: fmt.Sprintf("revoked %d instance(s)", len(members)),
|
||||
IP: c.ClientIP()})
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
// changeAccountPassword sets one password and pushes it everywhere.
|
||||
//
|
||||
// HQ's hash is the single source of truth for every hq-sourced row, and the
|
||||
// control plane has no local password-change path for them, so there is no
|
||||
// competing writer.
|
||||
//
|
||||
// Propagation is best-effort ON PURPOSE. Failing the password change because
|
||||
// one of three instances was briefly unreachable would leave the customer with
|
||||
// the password they were trying to get rid of; hqsync repairs a stale instance
|
||||
// within fifteen minutes, which is recoverable.
|
||||
func changeAccountPassword(c *gin.Context) {
|
||||
var body struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "current and new password are required"})
|
||||
return
|
||||
}
|
||||
if len(body.NewPassword) < 12 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "choose a password of at least 12 characters"})
|
||||
return
|
||||
}
|
||||
|
||||
s := auth.Current(c)
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var me models.CustomerUser
|
||||
if err := db.Admin("customer_users").FindOne(ctx,
|
||||
bson.M{"user_id": s.UserID}).Decode(&me); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
|
||||
return
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(me.PasswordHash), []byte(body.CurrentPassword)) != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "that is not your current password"})
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), auth.BcryptCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not set the password"})
|
||||
return
|
||||
}
|
||||
if _, err := db.Admin("customer_users").UpdateOne(ctx,
|
||||
bson.M{"user_id": me.UserID},
|
||||
bson.M{"$set": bson.M{"password_hash": string(hash)}}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not set the password"})
|
||||
return
|
||||
}
|
||||
|
||||
pending := false
|
||||
if n, err := cloudprov.SetPasswordHash(ctx, me.UserID, string(hash)); err != nil {
|
||||
pending = true
|
||||
now := time.Now().UTC()
|
||||
log.Printf("password: propagation for %s failed, hqsync will repair: %v", me.Email, err)
|
||||
_, _ = db.Admin("customer_users").UpdateOne(ctx,
|
||||
bson.M{"user_id": me.UserID},
|
||||
bson.M{"$set": bson.M{"hq_sync_failed_at": now}})
|
||||
} else {
|
||||
log.Printf("password: %s propagated to %d instance user(s)", me.Email, n)
|
||||
_, _ = db.Admin("customer_users").UpdateOne(ctx,
|
||||
bson.M{"user_id": me.UserID},
|
||||
bson.M{"$unset": bson.M{"hq_sync_failed_at": ""}})
|
||||
}
|
||||
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: me.Email, Action: "account_user.password_changed", AccountID: s.AccountID,
|
||||
Target: me.Email, IP: c.ClientIP()})
|
||||
c.JSON(http.StatusOK, gin.H{"updated": true, "propagation_pending": pending})
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mrhid6/vantage/admin/internal/auth"
|
||||
"github.com/mrhid6/vantage/admin/internal/config"
|
||||
"github.com/mrhid6/vantage/admin/internal/models"
|
||||
)
|
||||
|
||||
func Routes(cfg config.Config) http.Handler {
|
||||
@@ -36,17 +37,49 @@ func Routes(cfg config.Config) http.Handler {
|
||||
r.GET("/auth/verify", auth.HandleVerify)
|
||||
r.GET("/auth/me", getMe)
|
||||
r.POST("/auth/signup", auth.HandleSignup)
|
||||
r.POST("/auth/accept-invite", auth.HandleAcceptInvite)
|
||||
|
||||
cust := r.Group("/api")
|
||||
cust.Use(auth.RequireCustomer())
|
||||
{
|
||||
cust.GET("/account", getAccount)
|
||||
cust.POST("/instances", createInstance)
|
||||
|
||||
// People. Reading is open to any member; changing anything is
|
||||
// owner-or-admin, enforced per route rather than by splitting the group,
|
||||
// so the guard is visible next to the route it guards.
|
||||
cust.GET("/account/users", listAccountUsers)
|
||||
cust.POST("/account/users",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
inviteAccountUser)
|
||||
cust.PUT("/account/users/:id/role",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
updateAccountUserRole)
|
||||
cust.DELETE("/account/users/:id",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
deleteAccountUser)
|
||||
|
||||
// Any member may change their own password — it is theirs. There is no
|
||||
// endpoint for changing anyone else's.
|
||||
cust.PUT("/account/password", changeAccountPassword)
|
||||
|
||||
cust.POST("/instances",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
createInstance)
|
||||
cust.POST("/instances/link", linkInstance)
|
||||
cust.POST("/instances/:id/relink", relinkInstance)
|
||||
cust.POST("/instances/:id/renew", renewInstance)
|
||||
cust.GET("/instances/:id/license", getInstanceLicense)
|
||||
cust.GET("/instances/:id/license/download", downloadInstanceLicense)
|
||||
cust.GET("/instances/:id/members", listInstanceMembers)
|
||||
cust.POST("/instances/:id/members",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
grantInstanceMember)
|
||||
cust.PUT("/instances/:id/members/:uid/role",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
updateInstanceMemberRole)
|
||||
cust.DELETE("/instances/:id/members/:uid",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
revokeInstanceMember)
|
||||
cust.GET("/subscriptions", listSubscriptions)
|
||||
}
|
||||
|
||||
|
||||
@@ -476,7 +476,9 @@ func staffCreateAccountUser(c *gin.Context) {
|
||||
}
|
||||
|
||||
email := strings.ToLower(strings.TrimSpace(body.Email))
|
||||
if err := auth.CreateCustomerUser(ctx, accountID, email, body.Password); err != nil {
|
||||
// Staff attaching a legacy customer are attaching the person who runs that
|
||||
// account, so they get owner.
|
||||
if err := auth.CreateCustomerUser(ctx, accountID, email, body.Password, models.AccountRoleOwner); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -28,9 +28,9 @@ const BcryptCost = 12
|
||||
// SHA-256 hash stored, 24-hour expiry.
|
||||
const VerifyWindow = 24 * time.Hour
|
||||
|
||||
// CreateCustomerUser creates an unverified self-hosted customer login and emails
|
||||
// the verification link. Called during purchase (spec 5) and by staff.
|
||||
func CreateCustomerUser(ctx context.Context, accountID, email, password string) error {
|
||||
// CreateCustomerUser creates an unverified HQ login with a chosen password and
|
||||
// emails the verification link. Used by signup and by staff.
|
||||
func CreateCustomerUser(ctx context.Context, accountID, email, password, accountRole string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -49,6 +49,7 @@ func CreateCustomerUser(ctx context.Context, accountID, email, password string)
|
||||
AccountID: accountID,
|
||||
Email: strings.ToLower(strings.TrimSpace(email)),
|
||||
PasswordHash: string(hash),
|
||||
AccountRole: accountRole,
|
||||
VerifyTokenHash: hex.EncodeToString(sum[:]),
|
||||
VerifyTokenExpiry: &expiry,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
@@ -79,6 +80,91 @@ func CreateCustomerUser(ctx context.Context, accountID, email, password string)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateInvitedUser creates a passwordless, unverified member of an existing
|
||||
// account and emails them a link to set a password.
|
||||
//
|
||||
// The empty hash is load-bearing: bcrypt.CompareHashAndPassword against "" can
|
||||
// never succeed, so the row cannot sign in and cannot usefully be projected
|
||||
// into an instance until the invitee has been through /accept-invite. That is
|
||||
// also why a grant refuses an unverified user.
|
||||
func CreateInvitedUser(ctx context.Context, accountID, accountName, email, accountRole string) error {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return err
|
||||
}
|
||||
token := hex.EncodeToString(raw)
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
expiry := time.Now().UTC().Add(VerifyWindow)
|
||||
|
||||
u := models.CustomerUser{
|
||||
UserID: uuid.NewString(),
|
||||
AccountID: accountID,
|
||||
Email: strings.ToLower(strings.TrimSpace(email)),
|
||||
AccountRole: accountRole,
|
||||
VerifyTokenHash: hex.EncodeToString(sum[:]),
|
||||
VerifyTokenExpiry: &expiry,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if _, err := db.Admin("customer_users").InsertOne(ctx, u); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := mail.SendInvite(u.Email, accountName, token); err != nil {
|
||||
// Same rollback rule, and the same detached context, as signup: a row
|
||||
// whose link was never delivered can never be signed in to and holds
|
||||
// the unique index on email against the person it was meant for.
|
||||
rbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
||||
defer cancel()
|
||||
if _, dErr := db.Admin("customer_users").DeleteOne(rbCtx, bson.M{"user_id": u.UserID}); dErr != nil {
|
||||
log.Printf("invite: FAILED to roll back customer_user %s (%s) after mail error: %v",
|
||||
u.UserID, u.Email, dErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleAcceptInvite consumes an invitation token and sets the password.
|
||||
//
|
||||
// Verification and password-setting are one step for an invitee, because the
|
||||
// link IS the proof of address and there is nothing to verify separately.
|
||||
func HandleAcceptInvite(c *gin.Context) {
|
||||
var body struct {
|
||||
Token string `json:"token"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Token == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing token"})
|
||||
return
|
||||
}
|
||||
if len(body.Password) < 12 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "choose a password of at least 12 characters"})
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), BcryptCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not set the password"})
|
||||
return
|
||||
}
|
||||
|
||||
sum := sha256.Sum256([]byte(body.Token))
|
||||
now := time.Now().UTC()
|
||||
res, err := db.Admin("customer_users").UpdateOne(c.Request.Context(),
|
||||
bson.M{
|
||||
"verify_token_hash": hex.EncodeToString(sum[:]),
|
||||
"verify_token_expiry": bson.M{"$gt": now},
|
||||
},
|
||||
bson.M{
|
||||
"$set": bson.M{"verified_at": now, "password_hash": string(hash)},
|
||||
"$unset": bson.M{"verify_token_hash": "", "verify_token_expiry": ""},
|
||||
})
|
||||
if err != nil || res.MatchedCount == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "that link is invalid or has expired"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"accepted": true})
|
||||
}
|
||||
|
||||
// HandleSignup creates a self-hosted customer: an account, an unverified user,
|
||||
// and a verification email.
|
||||
//
|
||||
@@ -134,7 +220,7 @@ func HandleSignup(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := CreateCustomerUser(ctx, acct.AccountID, email, body.Password); err != nil {
|
||||
if err := CreateCustomerUser(ctx, acct.AccountID, email, body.Password, models.AccountRoleOwner); err != nil {
|
||||
// Roll the account back rather than strand one with no owner. Detached
|
||||
// from ctx for the same reason as the user rollback above: a stalled mail
|
||||
// server cancels the request, and a rollback that needs the request to
|
||||
@@ -163,10 +249,28 @@ func HandleVerify(c *gin.Context) {
|
||||
}
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
now := time.Now().UTC()
|
||||
ctx := c.Request.Context()
|
||||
hashed := hex.EncodeToString(sum[:])
|
||||
|
||||
res, err := db.Admin("customer_users").UpdateOne(c.Request.Context(),
|
||||
// Peek first. An invited row has no password yet, so consuming its token
|
||||
// here would verify an account nobody can sign in to and burn the only
|
||||
// link that could fix it.
|
||||
var u models.CustomerUser
|
||||
if err := db.Admin("customer_users").FindOne(ctx, bson.M{
|
||||
"verify_token_hash": hashed,
|
||||
"verify_token_expiry": bson.M{"$gt": now},
|
||||
}).Decode(&u); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "that link is invalid or has expired"})
|
||||
return
|
||||
}
|
||||
if u.PasswordHash == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"verified": false, "needs_password": true})
|
||||
return
|
||||
}
|
||||
|
||||
res, err := db.Admin("customer_users").UpdateOne(ctx,
|
||||
bson.M{
|
||||
"verify_token_hash": hex.EncodeToString(sum[:]),
|
||||
"verify_token_hash": hashed,
|
||||
"verify_token_expiry": bson.M{"$gt": now},
|
||||
},
|
||||
bson.M{
|
||||
|
||||
@@ -2,8 +2,12 @@ package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
"github.com/mrhid6/vantage/admin/internal/models"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
const ctxSession = "admin_session_obj"
|
||||
@@ -58,3 +62,50 @@ func RequireCustomer() gin.HandlerFunc {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
const ctxCustomerUser = "admin_customer_user"
|
||||
|
||||
// CurrentUser returns the calling customer's own row, loaded once per request
|
||||
// by RequireAccountRole.
|
||||
//
|
||||
// It is nil behind RequireCustomer alone. A handler that needs the role must
|
||||
// sit behind RequireAccountRole, which is the only thing that loads it.
|
||||
func CurrentUser(c *gin.Context) *models.CustomerUser {
|
||||
if v, ok := c.Get(ctxCustomerUser); ok {
|
||||
if u, ok := v.(*models.CustomerUser); ok {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequireAccountRole admits a customer holding one of the given account roles.
|
||||
//
|
||||
// The role is read from the database on every request rather than carried in
|
||||
// the session. A session lives 24 hours; a demotion that only takes effect
|
||||
// when someone signs out again is not a demotion.
|
||||
func RequireAccountRole(roles ...string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
s := Current(c)
|
||||
if s == nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
|
||||
return
|
||||
}
|
||||
var u models.CustomerUser
|
||||
if err := db.Admin("customer_users").FindOne(c.Request.Context(),
|
||||
bson.M{"user_id": s.UserID}).Decode(&u); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
|
||||
return
|
||||
}
|
||||
if !slices.Contains(roles, u.AccountRole) {
|
||||
// 403 rather than 404 here: this is the caller's OWN account, so
|
||||
// there is no existence to disclose — the 404 rule protects other
|
||||
// accounts' resources, not the caller's view of their own.
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"error": "your account role does not allow this"})
|
||||
return
|
||||
}
|
||||
c.Set(ctxCustomerUser, &u)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,9 +24,10 @@ import (
|
||||
// CreateInstance creates a control-plane instance and its owner.
|
||||
//
|
||||
// The owner's password hash is COPIED from the HQ account rather than shared.
|
||||
// Changing the password on either side does not propagate, and they diverge from
|
||||
// that moment — accepted deliberately, because propagating a hash across two
|
||||
// services' databases is a worse problem than two passwords that started equal.
|
||||
// HQ remains the single source of truth: a password change there copies the new
|
||||
// hash to every projected row (see SetPasswordHash), and hqsync repairs any that
|
||||
// a failed write left stale. The control plane has no local password-change path
|
||||
// for an hq-sourced row, so there is no competing writer.
|
||||
//
|
||||
// On owner-insert failure the instance is rolled back, so a failed provision
|
||||
// never leaves a slug permanently occupied by an instance nobody owns.
|
||||
@@ -83,3 +84,102 @@ func OwnerUserID(ctx context.Context, instanceID string) (string, error) {
|
||||
}
|
||||
return u.UserID, nil
|
||||
}
|
||||
|
||||
// GrantUser projects an HQ person into a control-plane instance.
|
||||
//
|
||||
// The password hash is copied from customer_users rather than re-derived: HQ
|
||||
// owns the password, and a grant that asked for a password again would create
|
||||
// a second credential for one person.
|
||||
//
|
||||
// The row is written with auth_source "hq" and hq_user_id set, which is what
|
||||
// makes the control plane refuse to edit it locally and what lets a password
|
||||
// change find it later.
|
||||
func GrantUser(ctx context.Context, instanceID, email, passwordHash, role, hqUserID string) (*sharedmodels.User, error) {
|
||||
u, err := provision.CreateUserWithHash(ctx, db.ControlDB(), instanceID,
|
||||
email, passwordHash, role, sharedmodels.AuthHQ)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := db.Control("users").UpdateOne(ctx,
|
||||
bson.M{"user_id": u.UserID},
|
||||
bson.M{"$set": bson.M{"hq_user_id": hqUserID}}); err != nil {
|
||||
// Unwind: a projected row with no hq_user_id is invisible to revoke and
|
||||
// to password propagation, which is worse than no row at all.
|
||||
_, _ = db.Control("users").DeleteOne(ctx, bson.M{"user_id": u.UserID})
|
||||
return nil, fmt.Errorf("set hq_user_id: %w", err)
|
||||
}
|
||||
u.HQUserID = hqUserID
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// RevokeUser deletes the projected row for one person in one instance.
|
||||
//
|
||||
// Deleting rather than disabling is deliberate: the control plane has no
|
||||
// concept of a disabled user, and a row that still exists is a row that can
|
||||
// still sign in.
|
||||
func RevokeUser(ctx context.Context, instanceID, hqUserID string) error {
|
||||
_, err := db.Control("users").DeleteOne(ctx, bson.M{
|
||||
"instance_id": instanceID,
|
||||
"hq_user_id": hqUserID,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// SetMemberRole changes a projected user's role inside one instance.
|
||||
func SetMemberRole(ctx context.Context, instanceID, hqUserID, role string) error {
|
||||
if !sharedmodels.ValidRole(role) {
|
||||
return fmt.Errorf("invalid role %q", role)
|
||||
}
|
||||
res, err := db.Control("users").UpdateOne(ctx,
|
||||
bson.M{"instance_id": instanceID, "hq_user_id": hqUserID},
|
||||
bson.M{"$set": bson.M{"role": role}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.MatchedCount == 0 {
|
||||
return fmt.Errorf("no projected user in instance %s", instanceID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountOtherOwners counts owners of an instance other than one HQ person.
|
||||
//
|
||||
// It counts CONTROL-PLANE owners, so an owner created locally inside the
|
||||
// instance counts too. That matters: refusing to revoke the last HQ owner of
|
||||
// an instance that has three local owners would be a refusal with no cause.
|
||||
//
|
||||
// $ne matches documents where the field is absent, which is exactly how a
|
||||
// locally-created owner is stored.
|
||||
func CountOtherOwners(ctx context.Context, instanceID, exceptHQUserID string) (int64, error) {
|
||||
return db.Control("users").CountDocuments(ctx, bson.M{
|
||||
"instance_id": instanceID,
|
||||
"role": sharedmodels.RoleOwner,
|
||||
"hq_user_id": bson.M{"$ne": exceptHQUserID},
|
||||
})
|
||||
}
|
||||
|
||||
// SetPasswordHash writes one hash to every row projected from one HQ person,
|
||||
// across every instance, and reports how many it changed.
|
||||
func SetPasswordHash(ctx context.Context, hqUserID, hash string) (int64, error) {
|
||||
res, err := db.Control("users").UpdateMany(ctx,
|
||||
bson.M{"hq_user_id": hqUserID},
|
||||
bson.M{"$set": bson.M{"password_hash": hash}})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.ModifiedCount, nil
|
||||
}
|
||||
|
||||
// ProjectedUsers returns every control-plane row projected from one HQ person.
|
||||
// hqsync uses it to compare hashes.
|
||||
func ProjectedUsers(ctx context.Context, hqUserID string) ([]sharedmodels.User, error) {
|
||||
cur, err := db.Control("users").Find(ctx, bson.M{"hq_user_id": hqUserID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var users []sharedmodels.User
|
||||
if err := cur.All(ctx, &users); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
@@ -118,5 +118,25 @@ func EnsureIndexes(ctx context.Context) error {
|
||||
return fmt.Errorf("index %s: %w", idx.coll, err)
|
||||
}
|
||||
}
|
||||
|
||||
// One person holds at most one user in one instance. This is the property
|
||||
// that makes a grant idempotent-by-refusal rather than silently doubling a
|
||||
// projection, and it mirrors users' own (instance_id, email) uniqueness.
|
||||
if _, err := Admin("instance_members").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "customer_user_id", Value: 1}},
|
||||
Options: options.Index().SetUnique(true).
|
||||
SetName("instance_customer_user_unique"),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("index instance_members.(instance_id,customer_user_id): %w", err)
|
||||
}
|
||||
for _, keys := range []bson.D{
|
||||
{{Key: "account_id", Value: 1}},
|
||||
{{Key: "customer_user_id", Value: 1}},
|
||||
} {
|
||||
if _, err := Admin("instance_members").Indexes().CreateOne(ctx,
|
||||
mongo.IndexModel{Keys: keys}); err != nil {
|
||||
return fmt.Errorf("index instance_members: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// Package hqsync keeps projected control-plane users consistent with the HQ
|
||||
// people they were projected from.
|
||||
//
|
||||
// It is separate from inject on purpose. inject writes exactly three licence
|
||||
// fields on `instances` and that narrowness is the reason admin's reach into
|
||||
// the control plane is reviewable at all; a password repair pass bolted onto it
|
||||
// would quietly turn it into "the package that writes whatever admin wants".
|
||||
// This one goes through cloudprov, which is the sanctioned user write path.
|
||||
package hqsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/admin/internal/cloudprov"
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
"github.com/mrhid6/vantage/admin/internal/models"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// Interval matches inject's reconciler. Fifteen minutes is the worst-case
|
||||
// staleness a password change can suffer, which the spec accepts as
|
||||
// recoverable.
|
||||
const Interval = 15 * time.Minute
|
||||
|
||||
// Reconcile compares every projected user's stored hash against the HQ hash it
|
||||
// came from, and repairs mismatches.
|
||||
//
|
||||
// The comparison is on the hash string, not the password: two bcrypt hashes of
|
||||
// one password differ by salt, so this repairs by COPYING HQ's hash rather than
|
||||
// re-hashing. That is also why propagation copies rather than re-derives.
|
||||
func Reconcile(ctx context.Context) (checked, repaired int, err error) {
|
||||
cur, err := db.Admin("customer_users").Find(ctx,
|
||||
bson.M{"password_hash": bson.M{"$nin": bson.A{nil, ""}}})
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
var people []models.CustomerUser
|
||||
if err := cur.All(ctx, &people); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
for _, p := range people {
|
||||
projected, err := cloudprov.ProjectedUsers(ctx, p.UserID)
|
||||
if err != nil {
|
||||
log.Printf("hqsync: read projections of %s: %v", p.Email, err)
|
||||
continue
|
||||
}
|
||||
stale := false
|
||||
for _, u := range projected {
|
||||
checked++
|
||||
if u.PasswordHash != p.PasswordHash {
|
||||
stale = true
|
||||
}
|
||||
}
|
||||
if !stale {
|
||||
// Clear a stale failure flag: the instances agree, whatever the
|
||||
// flag says. Nothing reads the flag to decide what to repair.
|
||||
if p.HQSyncFailedAt != nil {
|
||||
_, _ = db.Admin("customer_users").UpdateOne(ctx,
|
||||
bson.M{"user_id": p.UserID},
|
||||
bson.M{"$unset": bson.M{"hq_sync_failed_at": ""}})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
n, err := cloudprov.SetPasswordHash(ctx, p.UserID, p.PasswordHash)
|
||||
if err != nil {
|
||||
log.Printf("hqsync: repair %s: %v", p.Email, err)
|
||||
continue
|
||||
}
|
||||
repaired += int(n)
|
||||
log.Printf("hqsync: repaired %d projected user(s) for %s", n, p.Email)
|
||||
_, _ = db.Admin("customer_users").UpdateOne(ctx,
|
||||
bson.M{"user_id": p.UserID},
|
||||
bson.M{"$unset": bson.M{"hq_sync_failed_at": ""}})
|
||||
}
|
||||
return checked, repaired, nil
|
||||
}
|
||||
|
||||
// Start runs once at boot, then on a ticker until ctx is cancelled.
|
||||
//
|
||||
// The boot pass is for the same reason inject's is: the likeliest moment for a
|
||||
// half-applied write is a deploy or a crash, and waiting a full interval to
|
||||
// notice means a customer's new password does not work somewhere for fifteen
|
||||
// minutes after we already know how to fix it.
|
||||
func Start(ctx context.Context) {
|
||||
go func() {
|
||||
runOnce(ctx)
|
||||
t := time.NewTicker(Interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
runOnce(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func runOnce(ctx context.Context) {
|
||||
runCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
checked, repaired, err := Reconcile(runCtx)
|
||||
if err != nil {
|
||||
log.Printf("hqsync: %v", err)
|
||||
return
|
||||
}
|
||||
if repaired > 0 {
|
||||
log.Printf("hqsync: checked %d projected user(s), repaired %d", checked, repaired)
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,18 @@ func SendVerification(to, token string) error {
|
||||
link+"\n\nThis link expires in 24 hours.\n")
|
||||
}
|
||||
|
||||
// SendInvite asks someone to join an existing account and set their own
|
||||
// password. It names the account, because an unexpected invitation from a
|
||||
// service you have never used is otherwise indistinguishable from spam.
|
||||
func SendInvite(to, accountName, token string) error {
|
||||
link := fmt.Sprintf("%s/accept-invite?token=%s", cfg.PublicURL, token)
|
||||
return send(to, "You have been invited to "+sanitizeHeader(accountName)+" on Vantage",
|
||||
fmt.Sprintf("You have been invited to join %s on Vantage.\n\n"+
|
||||
"Set your password and finish joining:\n\n%s\n\n"+
|
||||
"This link expires in 24 hours. If you were not expecting this, ignore it — "+
|
||||
"nothing happens until you open the link.\n", accountName, link))
|
||||
}
|
||||
|
||||
// SendLicense delivers the blob inline. It is signed public data, not a secret —
|
||||
// it is useless on any instance other than the one it names.
|
||||
func SendLicense(to, instanceName, blob string) error {
|
||||
@@ -166,7 +178,7 @@ func SendInstanceReady(to, instanceName, loginURL string, expires time.Time) err
|
||||
body += fmt.Sprintf(
|
||||
"Your Free licence runs until %s. We will email you before then so you can renew it in one click.\n\n"+
|
||||
"Sign in with the same email address and password you use for your Vantage account. "+
|
||||
"Changing one does not change the other.\n",
|
||||
"Changing your Vantage HQ password changes it here too.\n",
|
||||
expires.Format("2 January 2006"))
|
||||
return send(to, instanceName+" is ready", body)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
"github.com/mrhid6/vantage/shared/license"
|
||||
sharedmodels "github.com/mrhid6/vantage/shared/models"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
// Backfill brings pre-phase-3 data up to the membership model.
|
||||
//
|
||||
// It runs on every boot and is idempotent by construction: both passes filter
|
||||
// on the absence of what they write. There is no migrations collection in
|
||||
// admin, and adding one for two `$exists: false` queries would be more
|
||||
// machinery than the job deserves.
|
||||
//
|
||||
// It lives in models rather than db for the same reason SeedPlans does: db is
|
||||
// the connection layer and importing models there is an import cycle.
|
||||
func Backfill(ctx context.Context) error {
|
||||
// Pass 1: every existing customer_user created their own account, so they
|
||||
// are all owners. A row with no account_role would otherwise be able to do
|
||||
// nothing at all once the guards land — including managing the account it
|
||||
// created.
|
||||
res, err := db.Admin("customer_users").UpdateMany(ctx,
|
||||
bson.M{"account_role": bson.M{"$exists": false}},
|
||||
bson.M{"$set": bson.M{"account_role": AccountRoleOwner}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.ModifiedCount > 0 {
|
||||
log.Printf("backfill: set account_role=owner on %d customer_users", res.ModifiedCount)
|
||||
}
|
||||
|
||||
// Pass 2: phase 2 created cloud instances and their owners without an
|
||||
// instance_members row, because the collection did not exist. Reconstruct
|
||||
// one per instance from the control-plane owner it actually created.
|
||||
cur, err := db.Admin("admin_instances").Find(ctx, bson.M{
|
||||
"deployment": license.DeploymentCloud,
|
||||
"status": bson.M{"$ne": StatusDeleted},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var instances []Instance
|
||||
if err := cur.All(ctx, &instances); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
created := 0
|
||||
for _, inst := range instances {
|
||||
n, err := db.Admin("instance_members").CountDocuments(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Only an hq-sourced owner can be reconstructed: a control-plane owner
|
||||
// with no hq_user_id was created inside the instance and belongs to
|
||||
// nobody on this side. Leaving it unrecorded is correct.
|
||||
var owner sharedmodels.User
|
||||
err = db.Control("users").FindOne(ctx, bson.M{
|
||||
"instance_id": inst.InstanceID,
|
||||
"role": sharedmodels.RoleOwner,
|
||||
"hq_user_id": bson.M{"$nin": bson.A{nil, ""}},
|
||||
}).Decode(&owner)
|
||||
if err != nil {
|
||||
if err != mongo.ErrNoDocuments {
|
||||
return err
|
||||
}
|
||||
log.Printf("backfill: instance %s has no hq-sourced owner; left unrecorded", inst.InstanceID)
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := db.Admin("instance_members").InsertOne(ctx, InstanceMember{
|
||||
MemberID: uuid.NewString(),
|
||||
AccountID: inst.AccountID,
|
||||
InstanceID: inst.InstanceID,
|
||||
CustomerUserID: owner.HQUserID,
|
||||
ControlUserID: owner.UserID,
|
||||
Role: sharedmodels.RoleOwner,
|
||||
Email: owner.Email,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
created++
|
||||
}
|
||||
if created > 0 {
|
||||
log.Printf("backfill: recorded %d pre-existing instance owners", created)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// Account roles.
|
||||
//
|
||||
// Deliberately the same three words as the control plane's own roles rather
|
||||
// than a second vocabulary: a customer who reads "admin" in the portal and
|
||||
// "admin" in their instance should not have to learn that they mean different
|
||||
// things. They govern different scopes — this one governs the HQ account —
|
||||
// but they mean the same thing about power.
|
||||
//
|
||||
// Billing stays owner-only. Owners and admins may invite people, create
|
||||
// instances and grant instance access.
|
||||
const (
|
||||
AccountRoleOwner = "owner"
|
||||
AccountRoleAdmin = "admin"
|
||||
AccountRoleMember = "member"
|
||||
)
|
||||
|
||||
func ValidAccountRole(r string) bool {
|
||||
switch r {
|
||||
case AccountRoleOwner, AccountRoleAdmin, AccountRoleMember:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AccountRoleAtLeastAdmin is the single definition of "may manage people and
|
||||
// instances". Every guard calls this rather than comparing strings, so widening
|
||||
// the rule is one edit.
|
||||
func AccountRoleAtLeastAdmin(r string) bool {
|
||||
return r == AccountRoleOwner || r == AccountRoleAdmin
|
||||
}
|
||||
|
||||
// InstanceMember records that one HQ person holds a projected user inside one
|
||||
// cloud instance.
|
||||
//
|
||||
// It is admin's index of the projection, not the authority: the control-plane
|
||||
// `users` row IS the access. This row exists so the portal can list who is on
|
||||
// an instance without reading the control plane, and so a password change can
|
||||
// find every row to update without scanning every instance.
|
||||
//
|
||||
// ControlUserID is the projected users.user_id. Role is the role that user
|
||||
// holds INSIDE the instance, which is not the person's account role.
|
||||
type InstanceMember struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
MemberID string `bson:"member_id" json:"member_id"`
|
||||
AccountID string `bson:"account_id" json:"account_id"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
CustomerUserID string `bson:"customer_user_id" json:"customer_user_id"`
|
||||
ControlUserID string `bson:"control_user_id" json:"control_user_id"`
|
||||
Role string `bson:"role" json:"role"`
|
||||
Email string `bson:"email" json:"email"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
@@ -155,19 +155,26 @@ type StaffUser struct {
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
// CustomerUser is a self-hosted customer's login. Cloud customers do not have
|
||||
// one — they authenticate against the control plane with credentials they
|
||||
// already hold.
|
||||
// CustomerUser is one person on an HQ account.
|
||||
//
|
||||
// AccountRole governs what they may do to the ACCOUNT — invite people, create
|
||||
// instances, grant access. It says nothing about what they may do inside any
|
||||
// instance; that is the role on their InstanceMember row.
|
||||
type CustomerUser struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
UserID string `bson:"user_id" json:"user_id"`
|
||||
AccountID string `bson:"account_id" json:"account_id"`
|
||||
Email string `bson:"email" json:"email"`
|
||||
PasswordHash string `bson:"password_hash" json:"-"`
|
||||
AccountRole string `bson:"account_role" json:"account_role"`
|
||||
VerifiedAt *time.Time `bson:"verified_at,omitempty" json:"verified_at,omitempty"`
|
||||
VerifyTokenHash string `bson:"verify_token_hash,omitempty" json:"-"`
|
||||
VerifyTokenExpiry *time.Time `bson:"verify_token_expiry,omitempty" json:"-"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
// HQSyncFailedAt is set when a password change could not be written to
|
||||
// every projected control-plane row. It is visibility only — hqsync repairs
|
||||
// by comparing hashes, not by reading this field.
|
||||
HQSyncFailedAt *time.Time `bson:"hq_sync_failed_at,omitempty" json:"hq_sync_failed_at,omitempty"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
type AuditEntry struct {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useState } from "react";
|
||||
import { API_BASE, ApiError, NotConnected, api } from "@/lib/api";
|
||||
import { NotConnectedPanel } from "@/components/NotConnected";
|
||||
import { LicenceDelivery } from "@/components/LicenceDelivery";
|
||||
import { MembersPanel } from "@/components/MembersPanel";
|
||||
import { RelinkPanel } from "@/components/RelinkPanel";
|
||||
import { StatePill } from "@/components/StatePill";
|
||||
import { formatDate, licenceState, limitLabel } from "@/lib/format";
|
||||
@@ -88,6 +89,15 @@ export default function InstancePage() {
|
||||
) : (
|
||||
<p className="text-ink-2">No licence has been issued for this instance yet.</p>
|
||||
)}
|
||||
|
||||
{instance.deployment === "cloud" ? (
|
||||
<MembersPanel instanceId={instance.instance_id} />
|
||||
) : (
|
||||
<p className="rounded border border-rule bg-panel p-5 text-ink-2">
|
||||
Users for this install are managed inside it, in Settings → Instance. We do not
|
||||
have access to your own deployment.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,8 +53,8 @@ export function CreateForm() {
|
||||
/>
|
||||
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
You sign in to it with this same email address and password. Changing one does not
|
||||
change the other afterwards.
|
||||
You sign in to it with this same email address and password. Changing your Vantage
|
||||
HQ password changes it here too.
|
||||
</p>
|
||||
|
||||
<Button type="submit" disabled={create.isPending || !name.trim()}>
|
||||
|
||||
@@ -17,6 +17,12 @@ export default function CustomerLayout({ children }: { children: React.ReactNode
|
||||
<Link href="/billing" className="text-ink-3">
|
||||
Billing
|
||||
</Link>
|
||||
<Link href="/users" className="text-ink-3">
|
||||
People
|
||||
</Link>
|
||||
<Link href="/settings" className="text-ink-3">
|
||||
Settings
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
<main className="mx-auto max-w-rail px-5 py-8">{children}</main>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Field } from "@/components/Field";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [current, setCurrent] = useState("");
|
||||
const [next, setNext] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState<string | null>(null);
|
||||
|
||||
const change = useMutation({
|
||||
mutationFn: () => api.changePassword(current, next),
|
||||
onSuccess: (res) => {
|
||||
setCurrent("");
|
||||
setNext("");
|
||||
setDone(
|
||||
res.propagation_pending
|
||||
? "Password changed. One of your instances could not be updated just now; it will catch up within fifteen minutes."
|
||||
: "Password changed everywhere.",
|
||||
);
|
||||
},
|
||||
onError: (e) =>
|
||||
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="grid gap-8">
|
||||
<header className="grid gap-2">
|
||||
<h1 className="text-3xl">Settings</h1>
|
||||
<p className="text-ink-2">
|
||||
Your password signs you in here and into every Vantage instance you belong to.
|
||||
Changing it changes all of them.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form
|
||||
className="grid max-w-md gap-4 rounded border border-rule bg-panel p-5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setDone(null);
|
||||
change.mutate();
|
||||
}}
|
||||
>
|
||||
<Field
|
||||
label="Current password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
label="New password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={next}
|
||||
onChange={(e) => setNext(e.target.value)}
|
||||
required
|
||||
minLength={12}
|
||||
hint="At least 12 characters."
|
||||
error={error ?? undefined}
|
||||
/>
|
||||
{done && <p className="text-[0.9rem] text-valid">{done}</p>}
|
||||
<Button type="submit" disabled={change.isPending || next.length < 12}>
|
||||
{change.isPending ? "Changing…" : "Change password"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { API_BASE, ApiError, NotConnected, api, type AccountRole } from "@/lib/api";
|
||||
import { useSession } from "@/lib/session";
|
||||
import { NotConnectedPanel } from "@/components/NotConnected";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Field } from "@/components/Field";
|
||||
|
||||
const ROLES: AccountRole[] = ["owner", "admin", "member"];
|
||||
|
||||
export function InvitePanel() {
|
||||
const qc = useQueryClient();
|
||||
const { session } = useSession();
|
||||
const [email, setEmail] = useState("");
|
||||
const [role, setRole] = useState<AccountRole>("member");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const users = useQuery({ queryKey: ["account-users"], queryFn: api.accountUsers });
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ["account-users"] });
|
||||
const fail = (e: unknown) =>
|
||||
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again.");
|
||||
|
||||
const invite = useMutation({
|
||||
mutationFn: () => api.invite(email.trim().toLowerCase(), role),
|
||||
onSuccess: () => {
|
||||
setEmail("");
|
||||
setRole("member");
|
||||
refresh();
|
||||
},
|
||||
onError: fail,
|
||||
});
|
||||
const setRoleFor = useMutation({
|
||||
mutationFn: (v: { id: string; role: AccountRole }) => api.setAccountRole(v.id, v.role),
|
||||
onSuccess: refresh,
|
||||
onError: fail,
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => api.removeAccountUser(id),
|
||||
onSuccess: refresh,
|
||||
onError: fail,
|
||||
});
|
||||
|
||||
if (users.error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
|
||||
|
||||
const myRole = session?.account_role;
|
||||
const canManage = myRole === "owner" || myRole === "admin";
|
||||
const assignable = myRole === "owner" ? ROLES : ROLES.filter((r) => r !== "owner");
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
{error && (
|
||||
<p className="rounded border border-expired bg-panel p-3 text-[0.9rem] text-expired">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<table className="w-full border-collapse text-left text-[0.9rem]">
|
||||
<thead>
|
||||
<tr className="border-b border-rule font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
<th className="py-2">Email</th>
|
||||
<th className="py-2">Account role</th>
|
||||
<th className="py-2">Status</th>
|
||||
<th className="py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(users.data ?? []).map((u) => {
|
||||
const isSelf = u.email === session?.email;
|
||||
return (
|
||||
<tr key={u.user_id} className="border-b border-rule-soft">
|
||||
<td className="py-2.5">
|
||||
{u.email}
|
||||
{isSelf && <span className="ml-2 text-ink-3">(you)</span>}
|
||||
</td>
|
||||
<td className="py-2.5">
|
||||
{canManage && !isSelf ? (
|
||||
<select
|
||||
value={u.account_role}
|
||||
onChange={(e) =>
|
||||
setRoleFor.mutate({
|
||||
id: u.user_id,
|
||||
role: e.target.value as AccountRole,
|
||||
})
|
||||
}
|
||||
className="rounded border border-rule bg-panel-2 px-2 py-1 font-mono text-[0.82rem] text-ink"
|
||||
>
|
||||
{assignable.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className="font-mono text-[0.82rem]">
|
||||
{u.account_role}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 text-ink-2">
|
||||
{u.verified_at ? "Active" : "Invitation pending"}
|
||||
</td>
|
||||
<td className="py-2.5 text-right">
|
||||
{canManage && !isSelf && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[0.82rem] font-semibold text-expired underline"
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
`Remove ${u.email}? They lose access to every instance on this account.`,
|
||||
)
|
||||
)
|
||||
remove.mutate(u.user_id);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{canManage && (
|
||||
<form
|
||||
className="grid max-w-md gap-4 rounded border border-rule bg-panel p-5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (email.trim()) invite.mutate();
|
||||
}}
|
||||
>
|
||||
<h2 className="text-xl">Invite someone</h2>
|
||||
<Field
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
hint="They choose their own password from the emailed link. Nothing happens until they open it."
|
||||
/>
|
||||
<label className="grid max-w-md gap-1.5">
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
Account role
|
||||
</span>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as AccountRole)}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
|
||||
>
|
||||
{assignable.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
An account role is not access to an instance. Give them that on the
|
||||
instance itself.
|
||||
</p>
|
||||
<Button type="submit" disabled={invite.isPending || !email.trim()}>
|
||||
{invite.isPending ? "Sending…" : "Send invitation"}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { InvitePanel } from "./InvitePanel";
|
||||
|
||||
export default function UsersPage() {
|
||||
return (
|
||||
<div className="grid gap-8">
|
||||
<header className="grid gap-2">
|
||||
<h1 className="text-3xl">People</h1>
|
||||
<p className="text-ink-2">
|
||||
Everyone on this account. Owners and admins can invite people and grant them
|
||||
access to instances; billing stays with owners.
|
||||
</p>
|
||||
</header>
|
||||
<InvitePanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Suspense, useState } from "react";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Field } from "@/components/Field";
|
||||
|
||||
function AcceptForm() {
|
||||
const token = useSearchParams().get("token") ?? "";
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const accept = useMutation({
|
||||
mutationFn: () => api.acceptInvite(token, password),
|
||||
onSuccess: () => setDone(true),
|
||||
onError: (e) =>
|
||||
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
|
||||
});
|
||||
|
||||
if (!token) return <p className="text-ink-2">That link is missing its token.</p>;
|
||||
if (done)
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<h1 className="text-3xl">You're in</h1>
|
||||
<p className="text-ink-2">Sign in with your email address and new password.</p>
|
||||
<Link href="/login" className="font-semibold text-accent underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid max-w-md gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
accept.mutate();
|
||||
}}
|
||||
>
|
||||
<h1 className="text-3xl">Choose a password</h1>
|
||||
<p className="text-ink-2">
|
||||
This password signs you into Vantage HQ and into every instance you are given
|
||||
access to. Nobody who invited you can see it.
|
||||
</p>
|
||||
<Field
|
||||
label="New password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={12}
|
||||
hint="At least 12 characters."
|
||||
error={error ?? undefined}
|
||||
/>
|
||||
<Button type="submit" disabled={accept.isPending || password.length < 12}>
|
||||
{accept.isPending ? "Setting…" : "Set password"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AcceptInvitePage() {
|
||||
return (
|
||||
<main className="mx-auto max-w-rail px-5 py-16">
|
||||
<Suspense fallback={<p className="text-ink-3">Loading…</p>}>
|
||||
<AcceptForm />
|
||||
</Suspense>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
function Verify() {
|
||||
const router = useRouter();
|
||||
const token = useSearchParams().get("token") ?? "";
|
||||
const { data, error, isLoading } = useQuery({
|
||||
queryKey: ["verify", token],
|
||||
@@ -15,6 +16,17 @@ function Verify() {
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// An invitation and a verification link are the same shape, and someone will
|
||||
// paste one into the other. The backend leaves an invite token unspent and
|
||||
// says so; send them where they can actually finish.
|
||||
const needsPassword = data?.needs_password === true;
|
||||
useEffect(() => {
|
||||
if (needsPassword) {
|
||||
router.replace(`/accept-invite?token=${encodeURIComponent(token)}`);
|
||||
}
|
||||
}, [needsPassword, token, router]);
|
||||
if (needsPassword) return <Message title="One moment…" body="Taking you to set a password." />;
|
||||
|
||||
if (!token)
|
||||
return (
|
||||
<Message
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { ApiError, api, type InstanceRole } from "@/lib/api";
|
||||
import { useSession } from "@/lib/session";
|
||||
import { Button } from "@/components/Button";
|
||||
|
||||
const ROLES: InstanceRole[] = ["owner", "admin", "member"];
|
||||
|
||||
/*
|
||||
* Absent entirely for self-hosted instances — the backend refuses those, and a
|
||||
* panel that renders controls the server will reject is a panel that lies.
|
||||
*/
|
||||
export function MembersPanel({ instanceId }: { instanceId: string }) {
|
||||
const qc = useQueryClient();
|
||||
const { session } = useSession();
|
||||
const [selected, setSelected] = useState("");
|
||||
const [role, setRole] = useState<InstanceRole>("member");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const members = useQuery({
|
||||
queryKey: ["members", instanceId],
|
||||
queryFn: () => api.members(instanceId),
|
||||
});
|
||||
const people = useQuery({ queryKey: ["account-users"], queryFn: api.accountUsers });
|
||||
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ["members", instanceId] });
|
||||
const fail = (e: unknown) =>
|
||||
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again.");
|
||||
|
||||
const grant = useMutation({
|
||||
mutationFn: () => api.grantMember(instanceId, selected, role),
|
||||
onSuccess: () => {
|
||||
setSelected("");
|
||||
setRole("member");
|
||||
refresh();
|
||||
},
|
||||
onError: fail,
|
||||
});
|
||||
const changeRole = useMutation({
|
||||
mutationFn: (v: { uid: string; role: InstanceRole }) =>
|
||||
api.setMemberRole(instanceId, v.uid, v.role),
|
||||
onSuccess: refresh,
|
||||
onError: fail,
|
||||
});
|
||||
const revoke = useMutation({
|
||||
mutationFn: (uid: string) => api.revokeMember(instanceId, uid),
|
||||
onSuccess: refresh,
|
||||
onError: fail,
|
||||
});
|
||||
|
||||
const myRole = session?.account_role;
|
||||
const canManage = myRole === "owner" || myRole === "admin";
|
||||
|
||||
const granted = new Set((members.data ?? []).map((m) => m.customer_user_id));
|
||||
const candidates = (people.data ?? []).filter(
|
||||
(p) => !granted.has(p.user_id) && p.verified_at,
|
||||
);
|
||||
const pending = (people.data ?? []).filter((p) => !p.verified_at).length;
|
||||
|
||||
return (
|
||||
<section className="grid gap-4 rounded border border-rule bg-panel p-5">
|
||||
<div className="grid gap-1">
|
||||
<h2 className="text-xl">Who can sign in</h2>
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
Each person here has a real user inside this instance and signs in with their
|
||||
Vantage HQ password.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-[0.9rem] text-expired">{error}</p>}
|
||||
|
||||
<ul className="grid gap-2">
|
||||
{(members.data ?? []).map((m) => (
|
||||
<li
|
||||
key={m.member_id}
|
||||
className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft pb-2"
|
||||
>
|
||||
<span>{m.email}</span>
|
||||
<span className="flex items-center gap-3">
|
||||
{canManage ? (
|
||||
<select
|
||||
value={m.role}
|
||||
onChange={(e) =>
|
||||
changeRole.mutate({
|
||||
uid: m.customer_user_id,
|
||||
role: e.target.value as InstanceRole,
|
||||
})
|
||||
}
|
||||
className="rounded border border-rule bg-panel-2 px-2 py-1 font-mono text-[0.82rem] text-ink"
|
||||
>
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className="font-mono text-[0.82rem]">{m.role}</span>
|
||||
)}
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[0.82rem] font-semibold text-expired underline"
|
||||
onClick={() => {
|
||||
if (confirm(`Remove ${m.email} from this instance?`))
|
||||
revoke.mutate(m.customer_user_id);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{members.data?.length === 0 && (
|
||||
<li className="text-ink-2">Nobody has been added yet.</li>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{canManage && (
|
||||
<form
|
||||
className="flex flex-wrap items-end gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (selected) grant.mutate();
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
Add someone
|
||||
</span>
|
||||
<select
|
||||
value={selected}
|
||||
onChange={(e) => setSelected(e.target.value)}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
|
||||
>
|
||||
<option value="">Choose a person…</option>
|
||||
{candidates.map((p) => (
|
||||
<option key={p.user_id} value={p.user_id}>
|
||||
{p.email}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
Role here
|
||||
</span>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as InstanceRole)}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
|
||||
>
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<Button type="submit" disabled={!selected || grant.isPending}>
|
||||
{grant.isPending ? "Adding…" : "Add"}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{canManage && pending > 0 && (
|
||||
<p className="text-[0.82rem] text-ink-3">
|
||||
{pending} invited {pending === 1 ? "person has" : "people have"} not accepted
|
||||
yet and cannot be added until they do.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+63
-1
@@ -53,16 +53,52 @@ async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const post = <T,>(path: string, payload?: unknown) =>
|
||||
req<T>(path, { method: "POST", body: payload ? JSON.stringify(payload) : undefined });
|
||||
|
||||
const put = <T,>(path: string, payload?: unknown) =>
|
||||
req<T>(path, { method: "PUT", body: payload ? JSON.stringify(payload) : undefined });
|
||||
|
||||
const del = <T,>(path: string) => req<T>(path, { method: "DELETE" });
|
||||
|
||||
// --- types ---------------------------------------------------------------
|
||||
|
||||
export type Deployment = "cloud" | "self_hosted";
|
||||
export type Tier = "free" | "professional" | "self_hosted";
|
||||
export type InstanceStatus = "awaiting_link" | "active" | "lapsed" | "cancelled" | "deleted";
|
||||
|
||||
/*
|
||||
* Two role vocabularies, same three words. AccountRole governs the HQ account:
|
||||
* who may invite, create instances and grant access. InstanceRole is the role a
|
||||
* projected user holds INSIDE one instance. A person can be an account member
|
||||
* and an instance owner at once — that is normal, not a mistake.
|
||||
*/
|
||||
export type AccountRole = "owner" | "admin" | "member";
|
||||
export type InstanceRole = "owner" | "admin" | "member";
|
||||
|
||||
export interface Session {
|
||||
kind: "staff" | "customer";
|
||||
email: string;
|
||||
account_id?: string;
|
||||
account_role?: AccountRole;
|
||||
}
|
||||
|
||||
export interface AccountUser {
|
||||
user_id: string;
|
||||
account_id: string;
|
||||
email: string;
|
||||
account_role: AccountRole;
|
||||
verified_at?: string | null;
|
||||
hq_sync_failed_at?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface InstanceMember {
|
||||
member_id: string;
|
||||
account_id: string;
|
||||
instance_id: string;
|
||||
customer_user_id: string;
|
||||
control_user_id: string;
|
||||
role: InstanceRole;
|
||||
email: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Limits {
|
||||
@@ -183,7 +219,9 @@ export const api = {
|
||||
signup: (payload: { name: string; email: string; password: string; website?: string }) =>
|
||||
post<{ pending: boolean }>("/auth/signup", payload),
|
||||
verify: (token: string) =>
|
||||
req<{ verified: boolean }>(`/auth/verify?token=${encodeURIComponent(token)}`),
|
||||
req<{ verified: boolean; needs_password?: boolean }>(
|
||||
`/auth/verify?token=${encodeURIComponent(token)}`,
|
||||
),
|
||||
|
||||
account: () => req<AccountResponse>("/api/account"),
|
||||
link: (instance_id: string, name: string) =>
|
||||
@@ -196,6 +234,30 @@ export const api = {
|
||||
licenseBlobUrl: (id: string) => `${API_BASE}/api/instances/${id}/license/download`,
|
||||
subscriptions: () => req<Subscription[]>("/api/subscriptions"),
|
||||
|
||||
accountUsers: () => req<AccountUser[]>("/api/account/users"),
|
||||
invite: (email: string, role: AccountRole) =>
|
||||
post<{ invited: boolean }>("/api/account/users", { email, role }),
|
||||
setAccountRole: (userId: string, role: AccountRole) =>
|
||||
put<{ ok: boolean }>(`/api/account/users/${userId}/role`, { role }),
|
||||
removeAccountUser: (userId: string) =>
|
||||
del<{ deleted: boolean }>(`/api/account/users/${userId}`),
|
||||
changePassword: (current_password: string, new_password: string) =>
|
||||
put<{ updated: boolean; propagation_pending: boolean }>("/api/account/password", {
|
||||
current_password,
|
||||
new_password,
|
||||
}),
|
||||
acceptInvite: (token: string, password: string) =>
|
||||
post<{ accepted: boolean }>("/auth/accept-invite", { token, password }),
|
||||
|
||||
members: (instanceId: string) =>
|
||||
req<InstanceMember[]>(`/api/instances/${instanceId}/members`),
|
||||
grantMember: (instanceId: string, user_id: string, role: InstanceRole) =>
|
||||
post<InstanceMember>(`/api/instances/${instanceId}/members`, { user_id, role }),
|
||||
setMemberRole: (instanceId: string, userId: string, role: InstanceRole) =>
|
||||
put<{ ok: boolean }>(`/api/instances/${instanceId}/members/${userId}/role`, { role }),
|
||||
revokeMember: (instanceId: string, userId: string) =>
|
||||
del<{ revoked: boolean }>(`/api/instances/${instanceId}/members/${userId}`),
|
||||
|
||||
staff: {
|
||||
accounts: (q?: string) =>
|
||||
req<Account[]>(`/api/staff/accounts${q ? `?q=${encodeURIComponent(q)}` : ""}`),
|
||||
|
||||
@@ -111,7 +111,9 @@ func deleteInstanceUser(c *gin.Context) {
|
||||
}
|
||||
|
||||
func orgUserErrStatus(err error) int {
|
||||
if errors.Is(err, services.ErrLastOwner) {
|
||||
// 409 rather than 403: the caller has the right to manage members, and the
|
||||
// request is refused because of the resource's state, not their permissions.
|
||||
if errors.Is(err, services.ErrLastOwner) || errors.Is(err, services.ErrHQManaged) {
|
||||
return http.StatusConflict
|
||||
}
|
||||
return http.StatusInternalServerError
|
||||
|
||||
@@ -16,6 +16,16 @@ import (
|
||||
|
||||
var ErrLastOwner = errors.New("this is the organization's last owner promote another member to owner first")
|
||||
|
||||
// ErrHQManaged is returned when a caller tries to change a user this instance
|
||||
// does not own.
|
||||
//
|
||||
// An hq-sourced row is projected from a Vantage HQ account: HQ owns its role,
|
||||
// its password and its existence. A role editable in two places is a role with
|
||||
// two answers, and the loser is whichever writer ran first. Refusing here
|
||||
// rather than merely hiding the control in web/ is the point — the API is the
|
||||
// boundary, the UI is a courtesy.
|
||||
var ErrHQManaged = errors.New("this member is managed in Vantage HQ; change their role or remove them from the HQ portal")
|
||||
|
||||
func CountUsers() (int64, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
@@ -122,6 +132,9 @@ func UpdateUserRole(instanceID, userID, role string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("user not found")
|
||||
}
|
||||
if target.AuthSource == models.AuthHQ {
|
||||
return ErrHQManaged
|
||||
}
|
||||
|
||||
if target.Role == models.RoleOwner && role != models.RoleOwner {
|
||||
others, err := countOtherOwners(instanceID, userID)
|
||||
@@ -146,6 +159,9 @@ func DeleteUser(instanceID, userID string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("user not found")
|
||||
}
|
||||
if target.AuthSource == models.AuthHQ {
|
||||
return ErrHQManaged
|
||||
}
|
||||
if target.Role == models.RoleOwner {
|
||||
others, err := countOtherOwners(instanceID, userID)
|
||||
if err != nil {
|
||||
|
||||
@@ -17,6 +17,12 @@ COPY . .
|
||||
ARG NEXT_PUBLIC_API_URL=http://localhost:8080
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
|
||||
# Empty default on purpose: a self-hosted install has no HQ portal, and the
|
||||
# "Managed in Vantage HQ" label falls back to plain text rather than linking
|
||||
# somewhere that does not serve them.
|
||||
ARG NEXT_PUBLIC_HQ_URL=
|
||||
ENV NEXT_PUBLIC_HQ_URL=$NEXT_PUBLIC_HQ_URL
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Runtime stage
|
||||
|
||||
@@ -69,6 +69,7 @@ function MembersCard() {
|
||||
|
||||
const isOwner = user?.role === "owner";
|
||||
const assignableRoles = isOwner ? ROLES : ROLES.filter((r) => r !== "owner");
|
||||
const hqUrl = process.env.NEXT_PUBLIC_HQ_URL ?? "";
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -113,7 +114,10 @@ function MembersCard() {
|
||||
{users.map((u: InstanceUser) => {
|
||||
const isSelf = u.user_id === user?.user_id;
|
||||
|
||||
const locked = isSelf || (u.role === "owner" && !isOwner);
|
||||
const managedByHQ = u.auth_source === "hq";
|
||||
// Locked here is a courtesy: the API returns 409 for an hq-sourced
|
||||
// role change or deletion whether or not this select is rendered.
|
||||
const locked = isSelf || managedByHQ || (u.role === "owner" && !isOwner);
|
||||
return (
|
||||
<Tr key={u.user_id}>
|
||||
<Td>
|
||||
@@ -138,22 +142,39 @@ function MembersCard() {
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge variant="neutral">{u.auth_source === "oidc" ? "SSO" : "Password"}</Badge>
|
||||
<Badge variant="neutral">
|
||||
{u.auth_source === "oidc" ? "SSO" : u.auth_source === "hq" ? "Vantage HQ" : "Password"}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td className="text-text-secondary">
|
||||
{u.last_login ? new Date(u.last_login).toLocaleString() : "Never"}
|
||||
</Td>
|
||||
<Td className="text-right">
|
||||
{!locked && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm(`Remove ${u.email} from this instance?`)) removeUser(u.user_id);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
{managedByHQ ? (
|
||||
hqUrl ? (
|
||||
<a
|
||||
href={hqUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs text-text-secondary underline"
|
||||
>
|
||||
Managed in Vantage HQ
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-xs text-text-tertiary">Managed in Vantage HQ</span>
|
||||
)
|
||||
) : (
|
||||
!locked && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm(`Remove ${u.email} from this instance?`)) removeUser(u.user_id);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</Td>
|
||||
</Tr>
|
||||
|
||||
+4
-1
@@ -335,7 +335,10 @@ export interface InstanceUser {
|
||||
instance_id: string;
|
||||
email: string;
|
||||
role: Role;
|
||||
auth_source: "local" | "oidc";
|
||||
// "hq" means the row was projected from a Vantage HQ account. Its role,
|
||||
// password and existence belong to HQ; this instance refuses to change them.
|
||||
auth_source: "local" | "oidc" | "hq";
|
||||
hq_user_id?: string;
|
||||
created_at: string;
|
||||
last_login?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user