mirror of
https://github.com/PrivateCaptcha/PrivateCaptcha.git
synced 2026-02-09 23:38:42 -06:00
Add functionality to move property. closes PrivateCaptcha/issues#151
This commit is contained in:
@@ -31,6 +31,7 @@ const (
|
||||
UsageEndpoint = "usage"
|
||||
ReadyEndpoint = "ready"
|
||||
LiveEndpoint = "live"
|
||||
MoveEndpoint = "move"
|
||||
NotificationEndpoint = "notification"
|
||||
SelfHostedEndpoint = "selfhosted"
|
||||
ActivationEndpoint = "activation"
|
||||
|
||||
@@ -2049,3 +2049,30 @@ func (s *BusinessStoreImpl) ExpireInternalTrials(ctx context.Context, from, to t
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BusinessStoreImpl) MoveProperty(ctx context.Context, property *dbgen.Property, org *dbgen.GetUserOrganizationsRow) (*dbgen.Property, error) {
|
||||
if s.querier == nil {
|
||||
return nil, ErrMaintenance
|
||||
}
|
||||
|
||||
if property.OrgID.Int32 == org.Organization.ID {
|
||||
slog.WarnContext(ctx, "Property is already in the destination org", "propID", property.ID, "orgID", property.OrgID.Int32)
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
|
||||
updatedProperty, err := s.querier.MoveProperty(ctx, &dbgen.MovePropertyParams{
|
||||
ID: property.ID,
|
||||
OrgID: Int(org.Organization.ID),
|
||||
OrgOwnerID: org.Organization.UserID,
|
||||
})
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to move property to another org", "propID", property.ID, "oldOrgID", property.OrgID.Int32, "newOrgID", org.Organization.ID, common.ErrAttr(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "Moved property to another org", "propID", property.ID, "oldOrgID", property.OrgID.Int32, "newOrgID", org.Organization.ID)
|
||||
|
||||
s.cacheProperty(ctx, updatedProperty)
|
||||
|
||||
return updatedProperty, nil
|
||||
}
|
||||
|
||||
@@ -400,6 +400,43 @@ func (q *Queries) GetUserPropertiesCount(ctx context.Context, orgOwnerID pgtype.
|
||||
return count, err
|
||||
}
|
||||
|
||||
const moveProperty = `-- name: MoveProperty :one
|
||||
UPDATE backend.properties SET org_id = $2, org_owner_id = $3, updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, name, external_id, org_id, creator_id, org_owner_id, domain, level, salt, growth, created_at, updated_at, deleted_at, validity_interval, allow_subdomains, allow_localhost, max_replay_count
|
||||
`
|
||||
|
||||
type MovePropertyParams struct {
|
||||
ID int32 `db:"id" json:"id"`
|
||||
OrgID pgtype.Int4 `db:"org_id" json:"org_id"`
|
||||
OrgOwnerID pgtype.Int4 `db:"org_owner_id" json:"org_owner_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) MoveProperty(ctx context.Context, arg *MovePropertyParams) (*Property, error) {
|
||||
row := q.db.QueryRow(ctx, moveProperty, arg.ID, arg.OrgID, arg.OrgOwnerID)
|
||||
var i Property
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.ExternalID,
|
||||
&i.OrgID,
|
||||
&i.CreatorID,
|
||||
&i.OrgOwnerID,
|
||||
&i.Domain,
|
||||
&i.Level,
|
||||
&i.Salt,
|
||||
&i.Growth,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.DeletedAt,
|
||||
&i.ValidityInterval,
|
||||
&i.AllowSubdomains,
|
||||
&i.AllowLocalhost,
|
||||
&i.MaxReplayCount,
|
||||
)
|
||||
return &i, err
|
||||
}
|
||||
|
||||
const softDeleteProperty = `-- name: SoftDeleteProperty :one
|
||||
UPDATE backend.properties SET deleted_at = NOW(), updated_at = NOW(), name = name || ' deleted_' || substr(md5(random()::text), 1, 8) WHERE id = $1 RETURNING id, name, external_id, org_id, creator_id, org_owner_id, domain, level, salt, growth, created_at, updated_at, deleted_at, validity_interval, allow_subdomains, allow_localhost, max_replay_count
|
||||
`
|
||||
|
||||
@@ -65,6 +65,7 @@ type Querier interface {
|
||||
GetUsersWithoutSubscription(ctx context.Context, dollar_1 []int32) ([]*User, error)
|
||||
InsertLock(ctx context.Context, arg *InsertLockParams) (*Lock, error)
|
||||
InviteUserToOrg(ctx context.Context, arg *InviteUserToOrgParams) (*OrganizationUser, error)
|
||||
MoveProperty(ctx context.Context, arg *MovePropertyParams) (*Property, error)
|
||||
Ping(ctx context.Context) (int32, error)
|
||||
RemoveUserFromOrg(ctx context.Context, arg *RemoveUserFromOrgParams) error
|
||||
RotateAPIKey(ctx context.Context, arg *RotateAPIKeyParams) (*APIKey, error)
|
||||
|
||||
@@ -17,6 +17,11 @@ UPDATE backend.properties SET name = $2, level = $3, growth = $4, validity_inter
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: MoveProperty :one
|
||||
UPDATE backend.properties SET org_id = $2, org_owner_id = $3, updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetOrgPropertyByName :one
|
||||
SELECT * from backend.properties WHERE org_id = $1 AND name = $2 AND deleted_at IS NULL;
|
||||
|
||||
|
||||
@@ -97,8 +97,10 @@ type propertyDashboardRenderContext struct {
|
||||
type propertySettingsRenderContext struct {
|
||||
propertyDashboardRenderContext
|
||||
difficultyLevelsRenderContext
|
||||
Orgs []*userOrg
|
||||
MinLevel int
|
||||
MaxLevel int
|
||||
CanMove bool
|
||||
}
|
||||
|
||||
func (pc *propertySettingsRenderContext) UpdateLevels() {
|
||||
@@ -653,7 +655,13 @@ func (s *Server) getOrgProperty(w http.ResponseWriter, r *http.Request) (*proper
|
||||
}
|
||||
|
||||
func (s *Server) getOrgPropertySettings(w http.ResponseWriter, r *http.Request) (*propertySettingsRenderContext, error) {
|
||||
propertyRenderCtx, _, err := s.getOrgProperty(w, r)
|
||||
ctx := r.Context()
|
||||
user, err := s.SessionUser(ctx, s.Session(w, r))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
propertyRenderCtx, property, err := s.getOrgProperty(w, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -661,6 +669,23 @@ func (s *Server) getOrgPropertySettings(w http.ResponseWriter, r *http.Request)
|
||||
renderCtx := &propertySettingsRenderContext{
|
||||
propertyDashboardRenderContext: *propertyRenderCtx,
|
||||
difficultyLevelsRenderContext: createDifficultyLevelsRenderContext(),
|
||||
Orgs: []*userOrg{},
|
||||
CanMove: false,
|
||||
}
|
||||
|
||||
// only property owner can move it around
|
||||
if user.ID == property.CreatorID.Int32 {
|
||||
if orgs, err := s.Store.Impl().RetrieveUserOrganizations(ctx, user); err == nil {
|
||||
renderCtx.Orgs = orgsToUserOrgs(orgs)
|
||||
|
||||
for _, org := range orgs {
|
||||
if (org.Organization.ID != property.OrgID.Int32) && (org.Level == dbgen.AccessLevelOwner) {
|
||||
slog.DebugContext(ctx, "Found at least one other user-owned org", "orgID", org.Organization.ID, "orgName", org.Organization.Name)
|
||||
renderCtx.CanMove = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderCtx.Tab = propertySettingsTabIndex
|
||||
|
||||
90
pkg/portal/property_enterprise.go
Normal file
90
pkg/portal/property_enterprise.go
Normal file
@@ -0,0 +1,90 @@
|
||||
//go:build enterprise
|
||||
|
||||
package portal
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/PrivateCaptcha/PrivateCaptcha/pkg/common"
|
||||
dbgen "github.com/PrivateCaptcha/PrivateCaptcha/pkg/db/generated"
|
||||
)
|
||||
|
||||
func (s *Server) moveProperty(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to read request body", common.ErrAttr(err))
|
||||
s.RedirectError(http.StatusBadRequest, w, r)
|
||||
return
|
||||
}
|
||||
|
||||
newOrgParam := strings.TrimSpace(r.FormValue(common.ParamOrg))
|
||||
newOrgID, err := strconv.Atoi(newOrgParam)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to parse new org ID", "value", newOrgParam, common.ErrAttr(err))
|
||||
s.RedirectError(http.StatusBadRequest, w, r)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.SessionUser(ctx, s.Session(w, r))
|
||||
if err != nil {
|
||||
s.RedirectError(http.StatusUnauthorized, w, r)
|
||||
return
|
||||
}
|
||||
|
||||
org, err := s.Org(user, r)
|
||||
if err != nil {
|
||||
s.RedirectError(http.StatusInternalServerError, w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if org.ID == int32(newOrgID) {
|
||||
// this shouldn't happen as we don't expose such option in FE
|
||||
slog.ErrorContext(ctx, "Attempt to move property to the same org", "orgID", newOrgID)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
property, err := s.Property(org, r)
|
||||
if err != nil {
|
||||
s.RedirectError(http.StatusBadRequest, w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// we can only move properties that we created
|
||||
canMove := user.ID == property.CreatorID.Int32
|
||||
if !canMove {
|
||||
slog.ErrorContext(ctx, "Not enough permissions to move property", "userID", user.ID,
|
||||
"orgUserID", org.UserID.Int32, "propertyUserID", property.CreatorID.Int32)
|
||||
s.RedirectError(http.StatusUnauthorized, w, r)
|
||||
return
|
||||
}
|
||||
|
||||
orgs, err := s.Store.Impl().RetrieveUserOrganizations(ctx, user)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to retrieve user orgs", common.ErrAttr(err))
|
||||
s.RedirectError(http.StatusInternalServerError, w, r)
|
||||
return
|
||||
}
|
||||
|
||||
idx := slices.IndexFunc(orgs, func(o *dbgen.GetUserOrganizationsRow) bool {
|
||||
return (o.Organization.ID == int32(newOrgID)) && (o.Level == dbgen.AccessLevelOwner)
|
||||
})
|
||||
if idx == -1 {
|
||||
slog.ErrorContext(ctx, "Org is not found in user owned orgs", "orgID", newOrgID, "userID", user.ID)
|
||||
s.RedirectError(http.StatusBadRequest, w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if updatedProperty, err := s.Store.Impl().MoveProperty(ctx, property, orgs[idx]); err == nil {
|
||||
propertyDashboardURL := s.PartsURL(common.OrgEndpoint, strconv.Itoa(int(updatedProperty.OrgID.Int32)), common.PropertyEndpoint, strconv.Itoa(int(updatedProperty.ID)))
|
||||
common.Redirect(propertyDashboardURL, http.StatusOK, w, r)
|
||||
} else {
|
||||
s.RedirectError(http.StatusInternalServerError, w, r)
|
||||
}
|
||||
}
|
||||
@@ -146,3 +146,63 @@ func TestPostNewOrgProperty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveProperty(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx := context.TODO()
|
||||
user, org1, err := db_tests.CreateNewAccountForTest(ctx, store, t.Name(), testPlan)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create account: %v", err)
|
||||
}
|
||||
|
||||
// Create a new property
|
||||
property, err := server.Store.Impl().CreateNewProperty(ctx, &dbgen.CreatePropertyParams{
|
||||
Name: "propertyName",
|
||||
OrgID: db.Int(org1.ID),
|
||||
CreatorID: org1.UserID,
|
||||
OrgOwnerID: org1.UserID,
|
||||
Domain: "example.com",
|
||||
Level: db.Int2(int16(common.DifficultyLevelMedium)),
|
||||
Growth: dbgen.DifficultyGrowthMedium,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create new property: %v", err)
|
||||
}
|
||||
|
||||
org2, err := store.Impl().CreateNewOrganization(ctx, t.Name()+"-another-org", user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create extra org: %v", err)
|
||||
}
|
||||
|
||||
srv := http.NewServeMux()
|
||||
_ = server.Setup(srv, portalDomain(), common.NoopMiddleware)
|
||||
|
||||
cookie, err := portal_tests.AuthenticateSuite(ctx, user.Email, srv, server.XSRF, server.Sessions.CookieName, server.Mailer.(*email.StubMailer))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set(common.ParamCSRFToken, server.XSRF.Token(strconv.Itoa(int(user.ID))))
|
||||
form.Set(common.ParamOrg, strconv.Itoa(int(org2.ID)))
|
||||
|
||||
req := httptest.NewRequest("POST", fmt.Sprintf("/org/%d/property/%d/move", org1.ID, property.ID), strings.NewReader(form.Encode()))
|
||||
req.AddCookie(cookie)
|
||||
req.Header.Set(common.HeaderContentType, common.ContentTypeURLEncoded)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
srv.ServeHTTP(w, req)
|
||||
|
||||
resp := w.Result()
|
||||
if resp.StatusCode != http.StatusSeeOther {
|
||||
t.Errorf("Unexpected status code %v", resp.StatusCode)
|
||||
}
|
||||
|
||||
properties, err := store.Impl().RetrieveOrgProperties(ctx, org2)
|
||||
if len(properties) != 1 || properties[0].ID != property.ID {
|
||||
t.Errorf("Property was not moved")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ type RenderConstants struct {
|
||||
IgnoreError string
|
||||
Terms string
|
||||
MaxReplayCount string
|
||||
MoveEndpoint string
|
||||
Org string
|
||||
}
|
||||
|
||||
func NewRenderConstants() *RenderConstants {
|
||||
@@ -105,6 +107,8 @@ func NewRenderConstants() *RenderConstants {
|
||||
IgnoreError: common.ParamIgnoreError,
|
||||
Terms: common.ParamTerms,
|
||||
MaxReplayCount: common.ParamMaxReplayCount,
|
||||
MoveEndpoint: common.MoveEndpoint,
|
||||
Org: common.ParamOrg,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,4 +36,5 @@ func (s *Server) setupEnterprise(router *http.ServeMux, rg *RouteGenerator, priv
|
||||
router.Handle(rg.Put(common.OrgEndpoint, arg(common.ParamOrg), common.MembersEndpoint), privateWrite.ThenFunc(s.joinOrg))
|
||||
router.Handle(rg.Delete(common.OrgEndpoint, arg(common.ParamOrg), common.MembersEndpoint), privateWrite.ThenFunc(s.leaveOrg))
|
||||
router.Handle(rg.Delete(common.OrgEndpoint, arg(common.ParamOrg), common.DeleteEndpoint), privateWrite.ThenFunc(s.deleteOrg))
|
||||
router.Handle(rg.Post(common.OrgEndpoint, arg(common.ParamOrg), common.PropertyEndpoint, arg(common.ParamProperty), common.MoveEndpoint), privateWrite.ThenFunc(s.moveProperty))
|
||||
}
|
||||
|
||||
30
web/layouts/property/settings-move-form.html
Normal file
30
web/layouts/property/settings-move-form.html
Normal file
@@ -0,0 +1,30 @@
|
||||
<div id="move-form" class="bg-white px-4 pb-4 pt-5 sm:p-6 sm:pb-6 sm:flex-1 sm:flex">
|
||||
<div class="sm:flex-1 sm:flex sm:items-start">
|
||||
<div class="mt-3 text-center sm:mt-0 sm:text-left sm:flex-1">
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-900" id="modal-title">Move property</h3>
|
||||
<div class="mt-2">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="{{ .Const.Days }}" class="pc-internal-form-label"> Organization </label>
|
||||
<div class="mt-2">
|
||||
<select name="{{ .Const.Org }}" class="pc-internal-form-select">
|
||||
{{ range $org := $.Params.Orgs }}
|
||||
{{ if and (eq $org.Level $.Const.OrgLevelOwner) (ne $.Params.Org.ID $org.ID) }}
|
||||
<option value="{{$org.ID}}">{{ $org.Name }}</option>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6">
|
||||
<button type="submit" class="pc-internal-form-button pc-internal-form-button-primary sm:ml-4 sm:w-auto">
|
||||
Move
|
||||
</button>
|
||||
<button type="button" class="pc-internal-form-button pc-internal-form-button-secondary sm:w-auto" @click="moveOpen = false">Cancel</button>
|
||||
</div>
|
||||
@@ -34,7 +34,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-gray-200" x-data="{deleteOpen: false}">
|
||||
<div class="divide-y divide-gray-200" x-data="{deleteOpen: false, moveOpen: false}">
|
||||
<div class="relative z-10" aria-labelledby="modal-title" role="dialog" aria-modal="true"
|
||||
x-show="deleteOpen"
|
||||
x-transition:enter="ease-out duration-300"
|
||||
@@ -89,7 +89,38 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ if $.Platform.Enterprise }}
|
||||
<div class="relative z-10" aria-labelledby="modal-title" role="dialog" aria-modal="true"
|
||||
x-show="moveOpen"
|
||||
x-transition:enter="ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
>
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div>
|
||||
|
||||
<div class="fixed inset-0 z-10 w-screen overflow-y-auto">
|
||||
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0"
|
||||
x-show="moveOpen"
|
||||
x-transition:enter="ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave="ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
|
||||
<div class="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:w-full sm:max-w-lg sm:flex">
|
||||
<form class="sm:flex-1 sm:flex sm:flex-col"
|
||||
hx-disabled-elt="input, button"
|
||||
hx-post="{{ partsURL .Const.OrgEndpoint .Params.Org.ID .Const.PropertyEndpoint .Params.Property.ID .Const.MoveEndpoint }}">
|
||||
{{template "settings-move-form.html" .}}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
<div class="grid max-w-4xl grid-cols-1 gap-x-10 gap-y-10 px-4 py-12 sm:px-6 md:grid-cols-3 lg:px-8">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold leading-7 text-gray-900">Basic settings</h2>
|
||||
@@ -107,6 +138,18 @@
|
||||
{{template "settings-basic-form.html" .}}
|
||||
</form>
|
||||
</div>
|
||||
{{ if $.Platform.Enterprise }}
|
||||
<div class="grid max-w-4xl grid-cols-1 gap-x-10 gap-y-10 px-4 py-16 sm:px-6 md:grid-cols-3 lg:px-8">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold leading-7 text-gray-900">Move property</h2>
|
||||
<p class="mt-1 text-sm leading-6 text-gray-600">Property will be moved from "{{.Params.Org.Name}}" to another organization you <i>own</i>.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start md:col-span-2">
|
||||
<button type="submit" {{ if not .Params.CanMove }}disabled{{ end }} class="pc-internal-form-button {{ if .Params.CanMove }}pc-internal-form-button-secondary{{ else }}pc-internal-form-button-disabled{{ end }}" @click="moveOpen = true">Move</button>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
<div class="grid max-w-4xl grid-cols-1 gap-x-10 gap-y-10 px-4 py-16 sm:px-6 md:grid-cols-3 lg:px-8">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold leading-7 text-gray-900">Delete property</h2>
|
||||
|
||||
Reference in New Issue
Block a user