Merge pull request #846 from warmbly/devin/1791216084-ready-test-workspaces

feat: provision ready-to-use Test workspaces for reviewer accounts
This commit is contained in:
Matthew Meszaros
2026-10-05 16:35:33 +00:00
committed by GitHub
34 changed files with 796 additions and 52 deletions
@@ -76,6 +76,7 @@ const columns: Column<AdminOrgListItem>[] = [
>
{o.name}
</Link>
{o.category === "test" && <StatusBadge tone="info" className="ml-2">Test</StatusBadge>}
{o.slug && <div className="mt-0.5 font-mono text-[11px] text-subtle-foreground">{o.slug}</div>}
</div>
),
+4 -3
View File
@@ -141,9 +141,10 @@ export default function TestersPage() {
description={
<>
Accounts for people outside the team. Each skips the emailed login code, because the
holder cannot read this instance&apos;s mail. Everything else still applies: the password,
holder cannot read this instance&apos;s mail, and arrives with onboarding complete. Authentication still requires the password,
the captcha and the sign-in risk assessment. A tester either gets a workspace of its own
or joins one that already exists. Its password stops working on the date you choose, and
or joins one that already exists. A new Test workspace includes paid-feature access and 100 test credits until the password expires.
An existing workspace keeps its plan and the role you select. Its password stops working on the date you choose, and
revoking it ends the password and signs out every session at once.
</>
}
@@ -160,7 +161,7 @@ export default function TestersPage() {
<div className="mt-0.5 text-[12.5px] text-muted-foreground">
{created.joined_existing
? "This account is a member of an existing workspace and will land in it on sign-in."
: "This account owns a new, empty workspace."}
: "This account owns a new Test workspace with paid-feature access and 100 test credits until the password expires."}
</div>
</div>
<Button size="sm" variant="outline" onClick={() => setCreated(null)}>
+1
View File
@@ -731,6 +731,7 @@ export interface AdminUserPreview {
// /admin/organizations* — workspace admin (read-only slice).
export interface AdminOrgListItem {
category: "standard" | "test";
id: string;
name: string;
slug?: string | null;
@@ -117,16 +117,20 @@ The admin panel has the same thing under **Testers**: create one, see the passwo
#### Which workspace a tester lands in
A tester either gets a workspace of its own or joins one that already exists, and the choice is on the create form.
A tester either gets a workspace of its own or joins one that already exists, and the choice is on the create form. Accounts created through **Testers** arrive with onboarding complete, regardless of that choice. Exempting an existing account with the CLI does not complete its onboarding or change its workspace plan.
Its own is the default and the safer one. The reviewer connects their own mailbox and exercises the app without reaching anything of yours, which is usually all an OAuth verification needs to see.
Its own is the default and the safer one. The reviewer connects their own mailbox and exercises the app without reaching anything of yours, which is usually all an OAuth verification needs to see. The workspace is categorized as **Test** in the admin organization list and gets a private managed Test plan: paid-product features, including mailbox OAuth, campaigns, warmup, Unibox and AI, with 100 test credits. The dashboard header reads **Test**, not a customer plan name, and the credit gauge labels its allowance **Test credits**. Its allowance includes 10,000 contacts, 100 campaigns (20 active), 10 team members and a workspace send budget of 1,000 emails per day. Mailbox send spacing, reputation checks, captcha, authentication and abuse controls still apply. The category is informational, not an authorization bypass.
The managed plan expires when the handed-out password expires, and revoking the tester ends that grant immediately. Credits do not bypass feature gating after the grant ends. Existing dedicated reviewer workspaces recorded by tester creation are categorized on upgrade; still-active reviewers receive the grant and credits unless the workspace already has a Stripe subscription or an operator-managed plan. Legacy admin-created reviewers without a password expiry get the default 30-day lifetime measured from their original creation, not from the upgrade. Expired or revoked reviewers receive no renewed access. Existing memberships and data are not deleted.
Connecting Gmail still needs configured `BOX_GOOGLE_CLIENT_ID` and `BOX_GOOGLE_CLIENT_SECRET`, and the backend and frontend mailbox OAuth flags must permit new connections. A Test plan does not supply provider credentials or change those flags. Dashboard Google sign-in uses the separate `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` configuration. See [configuration](/development/configuration/).
Joining an existing workspace is for a review judged on the app doing real work, where an empty workspace shows none of it. The tester becomes an ordinary member of that workspace and gets none of its own, so signing in lands straight in it: the dashboard skips its workspace picker only when someone belongs to exactly one. Two things follow from that, and both are the point rather than a side effect:
- **You pick the role, and there is no default.** This is the one path that grants workspace access without anybody in that workspace asking for it, so the form will not submit until a role is named. The role decides everything the holder can reach, and it is recorded on the audit row as well as the members table, which the role can be changed out of later.
- **The tester can see real data.** A role carrying `access_unibox` or `manage_emails` means real mail and real mailboxes, belonging to real people. Give the narrowest role the review actually needs, and revoke it when the review ends.
The tester occupies a seat like any other member, so a workspace at its team member limit refuses the join rather than quietly exceeding it.
The tester occupies a seat like any other member, so a workspace at its team member limit refuses the join rather than quietly exceeding it. Joining does not change the workspace category, grant a Test plan or add credits; its existing plan and the selected role still govern access.
`warmblyctl status` lists every exempt account on each run with its reason and the date, because the way this goes wrong is not granting one, it is forgetting it. Remove one with:
+2
View File
@@ -11,6 +11,8 @@ Everything here lives on the **AI & credits** tab of **Settings > Billing** and
## The two pools
A dedicated reviewer workspace starts with 100 test credits. Its header gauge and popover label them **Test credits** rather than plan credits. These use the normal credit ledger and spend rules, and do not keep paid features unlocked after the reviewer grant expires. Joining an existing workspace as a tester does not add credits. See [reviewer accounts](/development/accounts-and-access/#which-workspace-a-tester-lands-in).
| Pool | Refresh | Rollover |
|------|---------|----------|
| **Monthly allowance** | Reset to the plan amount each billing cycle | No, unused credits are replaced by the fresh grant |
+2
View File
@@ -7,6 +7,8 @@ Every hosted workspace starts free. The free workspace warms up to 10 mailboxes,
## Plans
Dedicated reviewer workspaces show **Test** in the header instead of a customer plan. They have operator-granted, time-limited access, not a purchased subscription. Joining an existing workspace as a tester does not change its plan badge. See [reviewer accounts](/development/accounts-and-access/#which-workspace-a-tester-lands-in).
| Plan | Monthly | Annual (per month) | Sends per day | Includes |
| --- | --- | --- | --- | --- |
| Starter | $29 | $23 | 150 | Unlimited warmup, unlimited mailboxes, unified inbox, team invitations, bulk contact operations |
@@ -139,6 +139,8 @@ If you have shell access to the source, `warmblyctl org export` writes the same
## What does not travel
The Test workspace classification is instance-local. Importing an archive does not classify the destination as Test or grant a managed Test plan or testing credits.
A few things belong to the instance rather than the workspace and are left out on purpose: the instance's link to Warmbly Cloud and which mailboxes it enrolled in the hosted pool ([Warmbly Cloud](/guides/warmbly-cloud/)), linked self-hosted instances on a cloud workspace, Google Workspace and Microsoft 365 admin grants, live sessions, in-flight OAuth handshakes and the organization's wrapped data key. Reconnect to Warmbly Cloud and make each admin grant again after the move.
[Mailbox imports](/guides/mailbox-import/) do not travel either, finished or running: an import is work the source instance is doing, and its rows hold credentials in flight. The mailboxes an import connected are ordinary mailboxes and move with the Workspace group, and the saved column mappings come too, so the same file maps itself on the destination. Let a running import finish before exporting: rows it has not connected yet are not in the archive.
+1 -6
View File
@@ -173,17 +173,12 @@ func (h *Handler) AdminCreateTester(c *gin.Context) {
if orgName == "" {
orgName = "Tester workspace"
}
org, oerr := h.OrganizationService.Create(c.Request.Context(), created.ID, orgName, "")
org, oerr := h.OrganizationService.CreateTesterWorkspace(c.Request.Context(), created.ID, *adminID, orgName, reason, passwordExpiresAt)
if oerr != nil {
h.undoHalfMadeTester(c, created.ID, oerr)
return
}
orgID = org.ID
if h.TrialService != nil {
// Best effort: without it the workspace has no subscription row and
// reads as unpaid, which is recoverable from the admin panel.
_ = h.TrialService.StartFreeTrialWithOrg(c.Request.Context(), created.ID, org.ID)
}
}
entry := map[string]any{
+92
View File
@@ -0,0 +1,92 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/mail"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/api/middleware"
"github.com/warmbly/warmbly/internal/app/organization"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
)
type testerUsers struct {
repository.UserRepository
userID uuid.UUID
until time.Time
}
func (r *testerUsers) GetUserByEmail(context.Context, string) (*models.User, error) { return nil, nil }
func (r *testerUsers) CreateExemptUser(_ context.Context, email *mail.Address, _, _ string, _ *uuid.UUID, until time.Time) (*models.User, error) {
r.until = until
return &models.User{ID: r.userID, Email: email.Address}, nil
}
type testerWorkspaces struct {
organization.OrganizationService
orgID, actor, roleID uuid.UUID
until time.Time
created, joined bool
}
func (s *testerWorkspaces) CreateTesterWorkspace(_ context.Context, _, actor uuid.UUID, _, _ string, until time.Time) (*models.Organization, *errx.Error) {
s.created, s.actor, s.until = true, actor, until
return &models.Organization{ID: s.orgID, Category: models.OrganizationCategoryTest}, nil
}
func (s *testerWorkspaces) AttachTester(_ context.Context, orgID, _, actor, roleID uuid.UUID) (*models.OrganizationMember, *errx.Error) {
s.joined, s.orgID, s.actor, s.roleID = true, orgID, actor, roleID
return &models.OrganizationMember{OrganizationID: orgID}, nil
}
func TestAdminCreateTesterOnlyGrantsDedicatedWorkspaces(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, existing := range []bool{false, true} {
actor, orgID, roleID := uuid.New(), uuid.New(), uuid.New()
users := &testerUsers{userID: uuid.New()}
orgs := &testerWorkspaces{orgID: orgID}
h := &Handler{UserRepo: users, OrganizationService: orgs}
r := gin.New()
r.Use(func(c *gin.Context) { c.Set(middleware.AdminUserIDKey, actor) })
r.POST("/testers", h.AdminCreateTester)
req := adminCreateTesterRequest{Email: "reviewer@example.test", Reason: "OAuth review", PasswordDays: 7}
if existing {
req.OrgID, req.RoleID = &orgID, &roleID
}
raw, err := json.Marshal(req)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/testers", strings.NewReader(string(raw))))
if w.Code != http.StatusOK {
t.Fatalf("create tester existing=%v: HTTP %d", existing, w.Code)
}
var response adminCreateTesterResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.Joined != existing || orgs.created == existing || orgs.joined != existing || orgs.actor != actor {
t.Fatalf("wrong provisioning path for existing=%v", existing)
}
if existing && orgs.roleID != roleID {
t.Fatal("existing workspace role changed")
}
if !existing && !orgs.until.Equal(users.until) {
t.Fatal("test entitlement did not use the reviewer password expiry")
}
if !response.PasswordExpiresAt.Equal(users.until) || !strings.HasPrefix(response.Password, "Tester-") {
t.Fatal("reviewer credentials response is incomplete")
}
}
}
+41
View File
@@ -0,0 +1,41 @@
package feature
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
)
func TestTestPlanAccessEndsWithReviewerExpiry(t *testing.T) {
ctx := context.Background()
orgID := uuid.New()
granted := time.Now().Add(-time.Hour)
for _, expired := range []bool{false, true} {
until := time.Now().Add(time.Hour)
if expired {
until = time.Now().Add(-time.Minute)
}
sub := &models.Subscription{PlanID: uuid.New(), ManagedAt: &granted, ManagedUntil: &until, ManagedPlanID: &models.TestPlanID}
gate := &featureGateService{subRepo: stubSubs{sub: sub}}
for name, check := range map[string]func(context.Context, uuid.UUID) (bool, *errx.Error){
"product": gate.IsPaidOrganization,
"campaigns": gate.CanSendCampaignEmail,
"premium pool": gate.HasPremiumWarmup,
"Unibox": gate.CanUseUnibox,
"writing assistant": gate.CanUseWritingAssistant,
"inbox agent": gate.CanUseInboxAgent,
} {
allowed, xerr := check(ctx, orgID)
if xerr != nil || allowed == expired {
t.Errorf("%s with expired=%v: allowed=%v, error=%v", name, expired, allowed, xerr)
}
}
if allowed, xerr := gate.CanUseWarmup(ctx, orgID); xerr != nil || !allowed {
t.Error("expiry must not remove normal free-pool warmup access")
}
}
}
@@ -0,0 +1,49 @@
package organization
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/models"
)
type testerCreateRepo struct {
createRepo
grantErr error
deleted bool
until time.Time
actor uuid.UUID
}
func (r *testerCreateRepo) ProvisionTesterWorkspace(_ context.Context, _, _, actor uuid.UUID, _ string, until time.Time) error {
r.actor, r.until = actor, until
return r.grantErr
}
func (r *testerCreateRepo) Delete(context.Context, uuid.UUID) error {
r.deleted = true
return nil
}
func TestCreateTesterWorkspaceRequiresACompleteGrant(t *testing.T) {
for _, fail := range []bool{false, true} {
r := &testerCreateRepo{}
if fail {
r.grantErr = errors.New("credit ledger unavailable")
}
s := &organizationService{orgRepo: r, userRepo: createUsers{}}
actor := uuid.New()
until := time.Now().Add(time.Hour)
org, xerr := s.CreateTesterWorkspace(context.Background(), uuid.New(), actor, "Reviewer workspace", "OAuth review", until)
if fail {
if xerr == nil || org != nil || !r.deleted {
t.Fatalf("failed grant left a usable workspace: %+v, %v, deleted=%v", org, xerr, r.deleted)
}
} else if xerr != nil || org.Category != models.OrganizationCategoryTest || !r.until.Equal(until) || r.actor != actor {
t.Fatalf("tester grant not complete: %+v, %v", org, xerr)
}
}
}
+22
View File
@@ -48,6 +48,7 @@ type OperatorNotifier interface {
// OrganizationService defines the interface for organization management
type OrganizationService interface {
CreateTesterWorkspace(ctx context.Context, userID, adminID uuid.UUID, name, reason string, until time.Time) (*models.Organization, *errx.Error)
// WireAuthPolicy attaches the deployment auth policy after construction.
WireAuthPolicy(p *config.AuthPolicy)
@@ -324,6 +325,7 @@ func (s *organizationService) Create(ctx context.Context, userID uuid.UUID, name
}
org := &models.Organization{
Category: models.OrganizationCategoryStandard,
ID: uuid.New(),
Name: name,
OwnerUserID: userID,
@@ -393,6 +395,26 @@ func (s *organizationService) Create(ctx context.Context, userID uuid.UUID, name
return org, nil
}
func (s *organizationService) CreateTesterWorkspace(ctx context.Context, userID, adminID uuid.UUID, name, reason string, until time.Time) (*models.Organization, *errx.Error) {
if !until.After(time.Now()) || strings.TrimSpace(reason) == "" {
return nil, errx.New(errx.BadRequest, "test access needs a reason and a future expiry")
}
org, xerr := s.Create(ctx, userID, name, "")
if xerr != nil {
return nil, xerr
}
if err := s.orgRepo.ProvisionTesterWorkspace(ctx, org.ID, userID, adminID, reason, until); err != nil {
errs.CaptureException(err)
if derr := s.orgRepo.Delete(ctx, org.ID); derr != nil {
errs.CaptureException(derr)
return nil, errx.New(errx.Internal, "could not provision or remove the test workspace; review it in the admin panel")
}
return nil, errx.New(errx.Internal, "could not provision the test workspace")
}
org.Category = models.OrganizationCategoryTest
return org, nil
}
// Get retrieves an organization by ID
func (s *organizationService) Get(ctx context.Context, orgID uuid.UUID) (*models.Organization, *errx.Error) {
org, err := s.orgRepo.GetByID(ctx, orgID)
+6
View File
@@ -38,3 +38,9 @@ func TestOrgRowStripsTheOperatorVerdict(t *testing.T) {
t.Errorf("manifest carries %d columns, want only the ordinary one", len(out))
}
}
func TestArchiveCannotSetTestWorkspaceCategory(t *testing.T) {
if !orgMergeExcluded["category"] {
t.Fatal("workspace imports must not overwrite the instance's test classification")
}
}
+1
View File
@@ -677,6 +677,7 @@ var orgMergeExcluded = func() map[string]bool {
out := map[string]bool{
"id": true,
"owner_user_id": true,
"category": true,
"slug": true,
"created_at": true,
"deletion_scheduled_at": true,
+2 -1
View File
@@ -13,7 +13,8 @@ func (s *userService) GetUser(ctx context.Context, userID uuid.UUID) (*models.Us
if err != nil {
return nil, err
}
if u != nil {
// Onboarding may have been completed by an operator or a migration.
if u != nil && u.OnboardingCompletedAt != nil {
return u, nil
}
+54
View File
@@ -0,0 +1,54 @@
package user
import (
"context"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/infrastructure/cache"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
)
type onboardedUserRepo struct {
repository.UserRepository
user *models.User
reads int
}
func (r *onboardedUserRepo) GetUser(context.Context, uuid.UUID) (*models.User, error) {
r.reads++
return r.user, nil
}
func TestLiveUserRefreshesCachedOnboardingAfterBackfill(t *testing.T) {
url := os.Getenv("WARMBLY_TEST_REDIS")
if url == "" {
t.Skip("WARMBLY_TEST_REDIS not set")
}
store, err := cache.New(url)
if err != nil {
t.Fatal(err)
}
defer store.Close()
ctx := context.Background()
userID := uuid.New()
t.Cleanup(func() { store.Del(ctx, CacheKey(userID)) })
now := time.Now()
repo := &onboardedUserRepo{user: &models.User{ID: userID, OnboardingCompletedAt: &now}}
svc := &userService{cache: store, userRepository: repo}
if xerr := svc.SaveUser(ctx, &models.User{ID: userID}); xerr != nil {
t.Fatal(xerr)
}
for range 2 {
u, xerr := svc.GetUser(ctx, userID)
if xerr != nil || u.OnboardingCompletedAt == nil {
t.Fatalf("cached onboarding was not refreshed: %v", xerr)
}
}
if repo.reads != 1 {
t.Fatalf("onboarded users should still be cached, repository reads=%d", repo.reads)
}
}
+14
View File
@@ -2,6 +2,20 @@ package config
import "testing"
func TestGoogleMailboxOAuthUsesBoxCredentials(t *testing.T) {
t.Setenv("BOX_GOOGLE_CLIENT_ID", "mailbox-client")
t.Setenv("BOX_GOOGLE_CLIENT_SECRET", "mailbox-secret")
t.Setenv("GOOGLE_CLIENT_ID", "sso-client")
t.Setenv("GOOGLE_CLIENT_SECRET", "sso-secret")
conf := GoogleOauth2Inbox("https://api.example.test")
if conf.ClientID != "mailbox-client" || conf.ClientSecret != "mailbox-secret" {
t.Fatal("mailbox OAuth must use BOX credentials, not dashboard SSO credentials")
}
if conf.RedirectURL != "https://api.example.test/addresses/google/callback" {
t.Fatalf("unexpected mailbox callback: %s", conf.RedirectURL)
}
}
func TestGoogleOAuthConnect(t *testing.T) {
for _, tt := range []struct {
name, flag, id, secret string
@@ -0,0 +1,8 @@
UPDATE subscriptions SET managed_at = NULL, managed_by = NULL, managed_reason = NULL,
managed_until = NULL, managed_plan_id = NULL, updated_at = now()
WHERE managed_plan_id = '00000000-0000-0000-0000-0000000000e1';
DELETE FROM plans p WHERE p.id = '00000000-0000-0000-0000-0000000000e1'
AND NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.plan_id = p.id);
ALTER TABLE organizations DROP COLUMN category;
@@ -0,0 +1,58 @@
ALTER TABLE organizations ADD COLUMN category text NOT NULL DEFAULT 'standard'
CHECK (category IN ('standard', 'test'));
INSERT INTO plans (id, name, max_contacts, daily_emails, ai_generation, account_limit,
price, discounted_price, duration_id, savings, public, monthly_credits,
max_campaigns, max_active_campaigns, max_team_members, max_email_accounts, daily_campaign_limit)
VALUES ('00000000-0000-0000-0000-0000000000e1', 'Test', 10000, 1000, true, 10,
0, 0, (SELECT id FROM durations WHERE title = 'month'), 0, false, 100,
100, 20, 10, 10, 100);
UPDATE users u SET password_expires_at = a.created_at + interval '30 days', updated_at = now()
FROM admin_audit_logs a
WHERE a.action = 'create_tester' AND a.target_id = u.id
AND u.login_code_exempt AND u.password_expires_at IS NULL;
UPDATE users SET onboarding_completed_at = COALESCE(login_code_exempt_at, created_at), updated_at = now()
WHERE onboarding_completed_at IS NULL AND login_code_exempt AND password_expires_at IS NOT NULL;
UPDATE organizations o SET category = 'test', updated_at = now()
FROM users u
WHERE o.owner_user_id = u.id
AND EXISTS (
SELECT 1 FROM admin_audit_logs a
WHERE a.action = 'create_tester' AND a.target_id = u.id
AND a.details->>'organization_id' = o.id::text
AND COALESCE(a.details->>'joined_existing', 'false') = 'false'
);
INSERT INTO subscriptions (user_id, organization_id, plan_id, stripe_customer_id,
managed_at, managed_by, managed_reason, managed_until, managed_plan_id)
SELECT u.id, o.id, '00000000-0000-0000-0000-000000000001', '',
now(), u.login_code_exempt_by, u.login_code_exempt_reason, u.password_expires_at,
'00000000-0000-0000-0000-0000000000e1'
FROM organizations o JOIN users u ON u.id = o.owner_user_id
WHERE o.category = 'test' AND u.login_code_exempt AND u.password_expires_at > now()
AND NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.organization_id = o.id);
UPDATE subscriptions s SET managed_at = now(), managed_by = u.login_code_exempt_by,
managed_reason = u.login_code_exempt_reason, managed_until = u.password_expires_at,
managed_plan_id = '00000000-0000-0000-0000-0000000000e1', updated_at = now()
FROM organizations o JOIN users u ON u.id = o.owner_user_id
WHERE s.organization_id = o.id AND o.category = 'test' AND u.login_code_exempt AND u.password_expires_at > now()
AND s.managed_at IS NULL AND s.stripe_subscription_id IS NULL;
WITH granted AS (
INSERT INTO credit_ledger (org_id, balance)
SELECT o.id, p.monthly_credits
FROM organizations o JOIN subscriptions s ON s.organization_id = o.id
JOIN plans p ON p.id = s.managed_plan_id
WHERE o.category = 'test' AND p.id = '00000000-0000-0000-0000-0000000000e1'
AND s.managed_until > now()
AND NOT EXISTS (SELECT 1 FROM credit_ledger_transactions t WHERE t.idempotency_key = o.id::text || ':tester_grant')
ON CONFLICT (org_id) DO UPDATE SET balance = credit_ledger.balance + EXCLUDED.balance, updated_at = now()
RETURNING org_id, balance, purchased_balance
)
INSERT INTO credit_ledger_transactions (org_id, amount, reason, balance_after, purchased_balance_after, idempotency_key)
SELECT org_id, p.monthly_credits, 'tester_grant', balance, purchased_balance, org_id::text || ':tester_grant'
FROM granted CROSS JOIN plans p WHERE p.id = '00000000-0000-0000-0000-0000000000e1';
+11 -10
View File
@@ -717,16 +717,17 @@ type AdminOrgSearch struct {
// summary state for the table (owner, counts, deletion status) without
// joining to plans or subscriptions — those land on the detail endpoint.
type AdminOrgListItem struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Slug *string `json:"slug,omitempty"`
OwnerUserID uuid.UUID `json:"owner_user_id"`
OwnerEmail string `json:"owner_email"`
OwnerFirstName string `json:"owner_first_name"`
OwnerLastName string `json:"owner_last_name"`
OwnerBannedAt *time.Time `json:"owner_banned_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
DeletionScheduledFor *time.Time `json:"deletion_scheduled_for,omitempty"`
Category OrganizationCategory `json:"category"`
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Slug *string `json:"slug,omitempty"`
OwnerUserID uuid.UUID `json:"owner_user_id"`
OwnerEmail string `json:"owner_email"`
OwnerFirstName string `json:"owner_first_name"`
OwnerLastName string `json:"owner_last_name"`
OwnerBannedAt *time.Time `json:"owner_banned_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
DeletionScheduledFor *time.Time `json:"deletion_scheduled_for,omitempty"`
// Resource counts. Cheap enough to inline on the list query so the
// table can show usage at a glance without an extra round-trip.
+15 -7
View File
@@ -6,15 +6,23 @@ import (
"github.com/google/uuid"
)
type OrganizationCategory string
const (
OrganizationCategoryStandard OrganizationCategory = "standard"
OrganizationCategoryTest OrganizationCategory = "test"
)
// Organization represents a multi-user organization/workspace
type Organization struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Slug *string `json:"slug,omitempty"`
AvatarURL *string `json:"avatar_url,omitempty"`
OwnerUserID uuid.UUID `json:"owner_user_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Category OrganizationCategory `json:"category"`
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Slug *string `json:"slug,omitempty"`
AvatarURL *string `json:"avatar_url,omitempty"`
OwnerUserID uuid.UUID `json:"owner_user_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Soft-delete window. When DeletionScheduledFor is non-nil the
// organization is "pending deletion" and will be hard-deleted at
+2
View File
@@ -10,6 +10,8 @@ import (
type Duration string
var TestPlanID = uuid.MustParse("00000000-0000-0000-0000-0000000000e1")
const (
DurationMonth Duration = "month"
DurationYear Duration = "year"
+10 -7
View File
@@ -14,6 +14,7 @@ import (
// OrganizationRepository defines the interface for organization data access
type OrganizationRepository interface {
ProvisionTesterWorkspace(ctx context.Context, orgID, ownerID, adminID uuid.UUID, reason string, until time.Time) error
// Organization CRUD
Create(ctx context.Context, org *models.Organization) error
GetByID(ctx context.Context, id uuid.UUID) (*models.Organization, error)
@@ -159,7 +160,7 @@ func (r *organizationRepository) GetByID(ctx context.Context, id uuid.UUID) (*mo
deletion_scheduled_at, deletion_scheduled_for,
presence_show_online, presence_show_activity,
product_description, icp_notes, voice_profile, inbox_agent_enabled,
assistant_shared_history, timezone
assistant_shared_history, timezone, category
FROM organizations WHERE id = $1
`
return r.scanOrganization(ctx, query, id)
@@ -172,7 +173,7 @@ func (r *organizationRepository) GetBySlug(ctx context.Context, slug string) (*m
deletion_scheduled_at, deletion_scheduled_for,
presence_show_online, presence_show_activity,
product_description, icp_notes, voice_profile, inbox_agent_enabled,
assistant_shared_history, timezone
assistant_shared_history, timezone, category
FROM organizations WHERE slug = $1
`
return r.scanOrganization(ctx, query, slug)
@@ -181,7 +182,7 @@ func (r *organizationRepository) GetBySlug(ctx context.Context, slug string) (*m
func (r *organizationRepository) scanOrganization(ctx context.Context, query string, args ...interface{}) (*models.Organization, error) {
row := r.db.QueryRow(ctx, query, args...)
var org models.Organization
err := row.Scan(&org.ID, &org.Name, &org.Slug, &org.AvatarURL, &org.OwnerUserID, &org.CreatedAt, &org.UpdatedAt, &org.DeletionScheduledAt, &org.DeletionScheduledFor, &org.PresenceShowOnline, &org.PresenceShowActivity, &org.ProductDescription, &org.ICPNotes, &org.VoiceProfile, &org.InboxAgentEnabled, &org.AssistantSharedHistory, &org.Timezone)
err := row.Scan(&org.ID, &org.Name, &org.Slug, &org.AvatarURL, &org.OwnerUserID, &org.CreatedAt, &org.UpdatedAt, &org.DeletionScheduledAt, &org.DeletionScheduledFor, &org.PresenceShowOnline, &org.PresenceShowActivity, &org.ProductDescription, &org.ICPNotes, &org.VoiceProfile, &org.InboxAgentEnabled, &org.AssistantSharedHistory, &org.Timezone, &org.Category)
if err == pgx.ErrNoRows {
return nil, nil
}
@@ -228,7 +229,7 @@ func (r *organizationRepository) GetUserOrganizations(ctx context.Context, userI
om.id, om.organization_id, om.user_id, om.role, om.permissions,
om.invited_by, om.invited_at, om.accepted_at,
o.id, o.name, o.slug, o.avatar_url, o.owner_user_id, o.created_at, o.updated_at,
o.deletion_scheduled_at, o.deletion_scheduled_for
o.deletion_scheduled_at, o.deletion_scheduled_for, o.category
FROM organization_members om
JOIN organizations o ON o.id = om.organization_id
WHERE om.user_id = $1
@@ -248,7 +249,7 @@ func (r *organizationRepository) GetUserOrganizations(ctx context.Context, userI
&m.ID, &m.OrganizationID, &m.UserID, &m.Role, &m.Permissions,
&m.InvitedBy, &m.InvitedAt, &m.AcceptedAt,
&org.ID, &org.Name, &org.Slug, &org.AvatarURL, &org.OwnerUserID, &org.CreatedAt, &org.UpdatedAt,
&org.DeletionScheduledAt, &org.DeletionScheduledFor,
&org.DeletionScheduledAt, &org.DeletionScheduledFor, &org.Category,
)
if err != nil {
return nil, err
@@ -266,7 +267,7 @@ func (r *organizationRepository) GetUserDefaultOrganization(ctx context.Context,
deletion_scheduled_at, deletion_scheduled_for,
presence_show_online, presence_show_activity,
product_description, icp_notes, voice_profile, inbox_agent_enabled,
assistant_shared_history, timezone
assistant_shared_history, timezone, category
FROM organizations WHERE owner_user_id = $1
ORDER BY created_at ASC LIMIT 1
`
@@ -769,7 +770,7 @@ const adminOrgListColumns = `
(SELECT COUNT(*) FROM campaigns c WHERE c.organization_id = o.id) AS campaign_count,
(SELECT COUNT(*) FROM campaigns c WHERE c.organization_id = o.id AND c.status = 'active') AS active_campaigns,
o.risk_state,
oa.utm_source, oa.utm_medium, oa.utm_campaign, oa.landing_path`
oa.utm_source, oa.utm_medium, oa.utm_campaign, oa.landing_path, o.category`
// adminOrgAcquisitionJoin brings in the signup channel. LEFT because most
// workspaces have no row: a direct signup carries nothing to record.
@@ -988,6 +989,7 @@ func (r *organizationRepository) SearchOrganizationsForAdmin(ctx context.Context
&item.MemberCount, &item.EmailAccountCount, &item.CampaignCount, &item.ActiveCampaigns,
&item.RiskState,
&item.UTMSource, &item.UTMMedium, &item.UTMCampaign, &item.LandingPath,
&item.Category,
&planName, &planPublic, &isEnterprise,
&managedAt, &managedReason, &managedUntil,
); err != nil {
@@ -1051,6 +1053,7 @@ func (r *organizationRepository) GetOrganizationAdminDetail(ctx context.Context,
&detail.MemberCount, &detail.EmailAccountCount, &detail.CampaignCount, &detail.ActiveCampaigns,
&detail.RiskState,
&detail.UTMSource, &detail.UTMMedium, &detail.UTMCampaign, &detail.LandingPath,
&detail.Category,
&detail.UpdatedAt, &detail.DeletionScheduledAt,
&detail.PlanName, &detail.SubscriptionStatus, &isEnterprise, &detail.CurrentPeriodEnd, &detail.TrialEnd,
)
@@ -0,0 +1,59 @@
package repository
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/models"
)
// ProvisionTesterWorkspace grants access only to a dedicated reviewer's workspace.
func (r *organizationRepository) ProvisionTesterWorkspace(ctx context.Context, orgID, ownerID, adminID uuid.UUID, reason string, until time.Time) error {
tx, err := r.db.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
var id uuid.UUID
if err := tx.QueryRow(ctx, `
SELECT o.id FROM organizations o JOIN users u ON u.id = o.owner_user_id
WHERE o.id = $1 AND o.owner_user_id = $2 AND u.login_code_exempt
AND u.password_expires_at = $3 AND u.password_expires_at > now()
AND NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.organization_id = o.id)
FOR UPDATE OF o`, orgID, ownerID, until).Scan(&id); err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE organizations SET category = 'test', updated_at = now() WHERE id = $1`, orgID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `
INSERT INTO subscriptions (user_id, organization_id, plan_id, stripe_customer_id,
managed_at, managed_by, managed_reason, managed_until, managed_plan_id)
VALUES ($1, $2, '00000000-0000-0000-0000-000000000001', '', now(), $3, $4, $5, $6)`,
ownerID, orgID, adminID, reason, until, models.TestPlanID); err != nil {
return err
}
var allowance int
if err := tx.QueryRow(ctx, `SELECT monthly_credits FROM plans WHERE id = $1`, models.TestPlanID).Scan(&allowance); err != nil {
return err
}
if allowance <= 0 {
return errors.New("the test plan needs a credit allowance")
}
var balance, purchased int
if err := tx.QueryRow(ctx, `
INSERT INTO credit_ledger (org_id, balance) VALUES ($1, $2)
ON CONFLICT (org_id) DO UPDATE SET balance = credit_ledger.balance + EXCLUDED.balance, updated_at = now()
RETURNING balance, purchased_balance`, orgID, allowance).Scan(&balance, &purchased); err != nil {
return err
}
if _, err := insertTxn(ctx, tx, orgID, allowance, "tester_grant", "", 0, balance, 0, purchased, scopeKey(orgID, "tester_grant")); err != nil {
return err
}
return tx.Commit(ctx)
}
+16 -9
View File
@@ -393,18 +393,19 @@ func (r *userRepository) CreateExemptUser(ctx context.Context, email *mail.Addre
firstName := displayname.FromEmail(address)
now := time.Now()
if _, ierr := tx.Exec(ctx, `
INSERT INTO users (id, email, password_hash, first_name, last_name, created_at, updated_at)
VALUES ($1, $2, $3, $4, '', $5, $5)`,
INSERT INTO users (id, email, password_hash, first_name, last_name, created_at, updated_at, onboarding_completed_at)
VALUES ($1, $2, $3, $4, '', $5, $5, $5)`,
id, address, passwordHash, firstName, now); ierr != nil {
return nil, ierr
}
created := &models.User{
ID: id,
FirstName: firstName,
Email: address,
Roles: make([]uuid.UUID, 0),
CreatedAt: now,
UpdatedAt: now,
OnboardingCompletedAt: &now,
ID: id,
FirstName: firstName,
Email: address,
Roles: make([]uuid.UUID, 0),
CreatedAt: now,
UpdatedAt: now,
}
if _, eerr := tx.Exec(ctx, `
UPDATE users
@@ -427,6 +428,12 @@ func (r *userRepository) CreateExemptUser(ctx context.Context, email *mail.Addre
func (r *userRepository) RevokeTester(ctx context.Context, id uuid.UUID) (bool, error) {
var cleared bool
err := r.DB.QueryRow(ctx, `
WITH ended_grants AS (
UPDATE subscriptions s SET managed_until = now(), updated_at = now()
FROM organizations o
WHERE s.organization_id = o.id AND o.owner_user_id = $1
AND o.category = 'test' AND s.managed_plan_id = $2
)
UPDATE users u
SET login_code_exempt = false,
login_code_exempt_reason = NULL,
@@ -438,7 +445,7 @@ func (r *userRepository) RevokeTester(ctx context.Context, id uuid.UUID) (bool,
updated_at = now()
FROM (SELECT id, password_expires_at IS NOT NULL AS tester FROM users WHERE id = $1 FOR UPDATE) old
WHERE u.id = old.id
RETURNING old.tester`, id).Scan(&cleared)
RETURNING old.tester`, id, models.TestPlanID).Scan(&cleared)
if errors.Is(err, pgx.ErrNoRows) {
return false, ErrUserNotFound
}
@@ -0,0 +1,207 @@
package repository
import (
"context"
"net/mail"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/models"
)
func TestLiveTesterWorkspaceProvisioning(t *testing.T) {
db, pool := liveContactDB(t)
requireSchemaVersion(t, pool, 264)
ctx := context.Background()
users := NewUserRepostory(db, nil)
orgs := NewOrganizationRepository(pool)
until := time.Now().Add(24 * time.Hour).Truncate(time.Microsecond)
u, err := users.CreateExemptUser(ctx, &mail.Address{Address: uuid.NewString() + "@example.test"}, "hash", "review", nil, until)
if err != nil {
t.Fatal(err)
}
slug := uuid.NewString()
org := &models.Organization{ID: uuid.New(), OwnerUserID: u.ID, Name: "Test workspace", Slug: &slug}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM organizations WHERE id = $1`, org.ID)
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, u.ID)
})
if u.OnboardingCompletedAt == nil {
t.Fatal("created reviewer must be onboarded")
}
stored, err := users.GetUser(ctx, u.ID)
if err != nil || stored.OnboardingCompletedAt == nil {
t.Fatalf("stored reviewer must be onboarded: %v", err)
}
if err := orgs.Create(ctx, org); err != nil {
t.Fatal(err)
}
// An invalid grant actor fails after categorization; the whole grant must roll back.
if err := orgs.ProvisionTesterWorkspace(ctx, org.ID, u.ID, uuid.New(), "review", until); err == nil {
t.Fatal("expected a foreign-key failure")
}
rolledBack, err := orgs.GetByID(ctx, org.ID)
if err != nil || rolledBack.Category != models.OrganizationCategoryStandard {
t.Fatalf("failed provisioning changed category: %v, %v", rolledBack, err)
}
var credits int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM credit_ledger WHERE org_id = $1`, org.ID).Scan(&credits); err != nil || credits != 0 {
t.Fatalf("failed provisioning minted credits: %d, %v", credits, err)
}
if err := orgs.ProvisionTesterWorkspace(ctx, org.ID, u.ID, u.ID, "review", until); err != nil {
t.Fatal(err)
}
provisioned, err := orgs.GetByID(ctx, org.ID)
if err != nil || provisioned.Category != models.OrganizationCategoryTest {
t.Fatalf("test category missing: %v, %v", provisioned, err)
}
adminDetail, err := orgs.GetOrganizationAdminDetail(ctx, org.ID)
if err != nil || adminDetail.Category != models.OrganizationCategoryTest {
t.Fatalf("admin category missing: %v, %v", adminDetail, err)
}
list, err := orgs.SearchOrganizationsForAdmin(ctx, &models.AdminOrgSearch{Query: slug, Limit: 10})
if err != nil || len(list.Data) != 1 || list.Data[0].Category != models.OrganizationCategoryTest {
t.Fatalf("admin list category missing: %v, %v", list, err)
}
subs := NewSubscriptionRepository(pool)
sub, err := subs.GetByOrganizationID(ctx, org.ID)
if err != nil || !sub.HasProductPlan() || sub.EffectivePlanID() != models.TestPlanID || !sub.ManagedUntil.Equal(until) {
t.Fatalf("test plan or expiry missing: %v, %v", sub, err)
}
if err := orgs.ProvisionTesterWorkspace(ctx, org.ID, u.ID, u.ID, "retry", until); err == nil {
t.Fatal("reprovisioning an existing workspace must fail")
}
var txns int
if err := pool.QueryRow(ctx, `SELECT balance FROM credit_ledger WHERE org_id = $1`, org.ID).Scan(&credits); err != nil || credits != 100 {
t.Fatalf("test credits = %d, %v", credits, err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM credit_ledger_transactions WHERE org_id = $1 AND reason = 'tester_grant'`, org.ID).Scan(&txns); err != nil || txns != 1 {
t.Fatalf("test grant audit = %d, %v", txns, err)
}
if cleared, err := users.RevokeTester(ctx, u.ID); err != nil || !cleared {
t.Fatalf("revoke = %v, %v", cleared, err)
}
sub, err = subs.GetByOrganizationID(ctx, org.ID)
if err != nil || sub.HasProductPlan() {
t.Fatalf("revocation left paid access: %v, %v", sub, err)
}
}
func TestLiveTesterWorkspaceBackfill(t *testing.T) {
_, pool := liveContactDB(t)
requireSchemaVersion(t, pool, 264)
ctx := context.Background()
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx.Rollback(ctx) }()
exec := func(sql string, args ...any) {
t.Helper()
if _, err := tx.Exec(ctx, sql, args...); err != nil {
t.Fatal(err)
}
}
readMigration := func(direction string) string {
t.Helper()
data, err := os.ReadFile("../infrastructure/db/migrations/000264_tester_workspaces." + direction + ".sql")
if err != nil {
t.Fatal(err)
}
return string(data)
}
exec(readMigration("down"))
for _, tc := range []struct {
name string
joined, expired, revoked, cli, legacy, paid, managed, missingSubscription bool
category string
grant, onboarded bool
}{
{name: "dedicated", category: "test", grant: true, onboarded: true},
{name: "dedicated without subscription", missingSubscription: true, category: "test", grant: true, onboarded: true},
{name: "existing", joined: true, category: "standard", onboarded: true},
{name: "expired", expired: true, category: "test", onboarded: true},
{name: "revoked", revoked: true, category: "test"},
{name: "CLI exemption", cli: true, legacy: true, category: "standard"},
{name: "legacy dedicated", legacy: true, category: "test", grant: true, onboarded: true},
{name: "legacy expired", legacy: true, expired: true, category: "test", onboarded: true},
{name: "Stripe workspace", paid: true, category: "test", onboarded: true},
{name: "managed workspace", managed: true, category: "test", onboarded: true},
} {
t.Run(tc.name, func(t *testing.T) {
exec := func(sql string, args ...any) {
t.Helper()
if _, err := tx.Exec(ctx, sql, args...); err != nil {
t.Fatal(err)
}
}
userID, orgID := uuid.New(), uuid.New()
until := time.Now().Add(time.Hour)
if tc.expired {
until = time.Now().Add(-time.Hour)
}
var expiry *time.Time
if !tc.legacy && !tc.revoked {
expiry = &until
}
exec(`INSERT INTO users (id, first_name, last_name, email, password_hash, login_code_exempt, login_code_exempt_reason, login_code_exempt_at, password_expires_at)
VALUES ($1, 'Reviewer', '', $2, 'hash', $3, 'review', now(), $4)`, userID, userID.String()+"@example.test", !tc.revoked, expiry)
exec(`INSERT INTO organizations (id, name, owner_user_id) VALUES ($1, 'Reviewer workspace', $2)`, orgID, userID)
if !tc.cli {
exec(`INSERT INTO admin_audit_logs (admin_user_id, action, target_type, target_id, details)
VALUES ($1, 'create_tester', 'user', $1, jsonb_build_object('organization_id', $2::text, 'joined_existing', $3::boolean))`, userID, orgID.String(), tc.joined)
}
if tc.legacy {
exec(`UPDATE admin_audit_logs SET details = details - 'joined_existing' WHERE target_id = $1`, userID)
if tc.expired {
exec(`UPDATE admin_audit_logs SET created_at = now() - interval '40 days' WHERE target_id = $1`, userID)
}
}
var stripeID *string
if tc.paid {
id := "sub_" + orgID.String()
stripeID = &id
}
if !tc.missingSubscription {
exec(`INSERT INTO subscriptions (user_id, organization_id, plan_id, stripe_customer_id, stripe_subscription_id)
VALUES ($1, $2, '00000000-0000-0000-0000-000000000001', '', $3)`, userID, orgID, stripeID)
}
if tc.managed {
exec(`UPDATE subscriptions SET managed_at = now(), managed_plan_id = plan_id, managed_reason = 'operator grant' WHERE organization_id = $1`, orgID)
}
exec(`INSERT INTO credit_ledger (org_id, balance, purchased_balance) VALUES ($1, 25, 75)`, orgID)
exec(readMigration("up"))
var category string
var managedID *uuid.UUID
var completed, storedExpiry *time.Time
var balance, purchased int
if err := tx.QueryRow(ctx, `SELECT o.category, s.managed_plan_id, u.onboarding_completed_at, u.password_expires_at, c.balance, c.purchased_balance
FROM organizations o JOIN users u ON u.id = o.owner_user_id
JOIN subscriptions s ON s.organization_id = o.id JOIN credit_ledger c ON c.org_id = o.id WHERE o.id = $1`, orgID).
Scan(&category, &managedID, &completed, &storedExpiry, &balance, &purchased); err != nil {
t.Fatal(err)
}
granted := managedID != nil && *managedID == models.TestPlanID
wantBalance := 25
if tc.grant {
wantBalance += 100
}
if category != tc.category || granted != tc.grant || (completed != nil) != tc.onboarded || balance != wantBalance || purchased != 75 {
t.Fatalf("category=%s granted=%v onboarded=%v balance=%d purchased=%d", category, granted, completed != nil, balance, purchased)
}
if tc.legacy && !tc.cli && storedExpiry == nil {
t.Fatal("legacy reviewer did not receive a bounded password lifetime")
}
if tc.legacy && tc.expired && storedExpiry.After(time.Now()) {
t.Fatal("upgrade renewed an expired legacy reviewer")
}
if tc.managed && (managedID == nil || *managedID == models.TestPlanID) {
t.Fatal("migration overwrote an operator grant")
}
exec(readMigration("down"))
})
}
}
+10 -5
View File
@@ -26,8 +26,10 @@ import { usePermission } from "@/hooks/usePermission";
import useAiMetered from "@/hooks/useAiMetered";
import { DitherMeter } from "@/components/ui/dither";
import { cn } from "@/lib/utils";
import { useAppStore } from "@/stores";
export function CreditsMeter() {
const isTest = useAppStore((s) => s.currentOrganization?.category === "test");
const canSee = usePermission("MANAGE_BILLING");
const metered = useAiMetered();
const credits = useCredits();
@@ -72,8 +74,8 @@ export function CreditsMeter() {
: 0;
const label = spendWindow
? `${spendWindow.spent.toLocaleString()} of ${spendWindow.limit.toLocaleString()} credits used ${spendWindow.word}`
: `${c.monthly_balance.toLocaleString()} of ${c.monthly_allowance.toLocaleString()} plan credits left`;
? `${spendWindow.spent.toLocaleString()} of ${spendWindow.limit.toLocaleString()} ${isTest ? "test credits" : "credits"} used ${spendWindow.word}`
: `${c.monthly_balance.toLocaleString()} of ${c.monthly_allowance.toLocaleString()} ${isTest ? "test" : "plan"} credits left`;
const extraLabel =
c.purchased_balance > 0 ? `, plus ${c.purchased_balance.toLocaleString()} extra` : "";
@@ -94,6 +96,7 @@ export function CreditsMeter() {
)}
>
<Ring fraction={fraction} />
{isTest && <span>Test credits</span>}
{spendWindow ? (
<span className="flex items-baseline gap-1">
<span className="flex items-baseline">
@@ -126,7 +129,7 @@ export function CreditsMeter() {
transition={{ duration: 0.12 }}
className="absolute right-0 top-full mt-1.5 w-72 rounded-md border border-slate-200 bg-white shadow-[0_12px_32px_-8px_rgba(15,23,42,0.18)] z-50 overflow-hidden"
>
<MeterPanel credits={c} settings={settings.data} low={low} empty={empty} />
<MeterPanel credits={c} settings={settings.data} low={low} empty={empty} isTest={isTest} />
<Link
to="/app/settings/billing/{-$tab}"
params={{ tab: "ai-credits" }}
@@ -149,11 +152,13 @@ function MeterPanel({
settings,
low,
empty,
isTest,
}: {
credits: CreditBalance;
settings?: AISpendSettings;
low: boolean;
empty: boolean;
isTest: boolean;
}) {
const usage = useCreditUsage();
const planUsed = Math.max(0, credits.monthly_allowance - credits.monthly_balance);
@@ -167,7 +172,7 @@ function MeterPanel({
<div className="flex items-end justify-between">
<div>
<div className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">
AI credits
{isTest ? "Test credits" : "AI credits"}
</div>
<div className="mt-1 flex items-baseline gap-1.5">
<AnimatedNumber
@@ -200,7 +205,7 @@ function MeterPanel({
<div>
<div className="flex items-baseline justify-between text-[11.5px]">
<span className="text-slate-600">Plan credits</span>
<span className="text-slate-600">{isTest ? "Test credits" : "Plan credits"}</span>
<span className="tabular-nums text-slate-900 font-medium">
{credits.monthly_balance.toLocaleString()}
<span className="text-slate-400 font-normal">
+13
View File
@@ -11,6 +11,7 @@ import useCloudPool from "@/hooks/useCloudPool";
import { usePermission } from "@/hooks/usePermission";
import { PLAN_ACCENT_CLASSES, getPlan } from "@/lib/plans";
import { cn } from "@/lib/utils";
import { useAppStore } from "@/stores";
export function PlanPill() {
const access = useFeatureAccess();
@@ -18,6 +19,18 @@ export function PlanPill() {
// The Warmbly Cloud page is manage-settings gated; everyone else lands
// on their profile rather than on a "no access" screen.
const cloudTo = usePermission("MANAGE_SETTINGS") ? "/app/settings/warmbly-cloud" : "/app/settings/profile";
const isTest = useAppStore((s) => s.currentOrganization?.category === "test");
if (isTest) {
return (
<Badge
to="/app/settings/profile"
className="bg-sky-50 text-sky-700 border-sky-100"
dot="bg-sky-500"
label="Test"
title="Dedicated tester workspace"
/>
);
}
if (access.loading) {
return (
<div className="h-6 w-16 rounded border border-slate-200 bg-slate-100 animate-pulse" />
@@ -0,0 +1,75 @@
import type { PropsWithChildren } from "react";
import { fireEvent, render, renderHook, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAppStore } from "@/stores";
import useFeatureAccess from "@/hooks/useFeatureAccess";
import { PlanPill } from "./PlanPill";
import { CreditsMeter } from "./CreditsMeter";
const fixture = vi.hoisted(() => ({
subscription: {
data: { managed: true, status: "incomplete", plan: { id: "00000000-0000-0000-0000-0000000000e1", name: "Test" } },
isPending: false,
},
}));
vi.mock("@tanstack/react-router", () => ({
Link: ({ children, to, title, className }: PropsWithChildren<{ to: string; title?: string; className?: string }>) => (
<a href={to} title={title} className={className}>{children}</a>
),
}));
vi.mock("@/lib/api/hooks/app/subscription/useSubscription", () => ({ default: () => fixture.subscription }));
vi.mock("@/lib/api/hooks/auth/useAuthConfig", () => ({ default: () => ({ data: { billing_enabled: true }, isLoading: false }) }));
vi.mock("@/hooks/useCloudPool", () => ({ default: () => ({}) }));
vi.mock("@/hooks/usePermission", () => ({ usePermission: () => true }));
vi.mock("@/hooks/useAiMetered", () => ({ default: () => true }));
vi.mock("@/lib/api/hooks/app/subscription/useCredits", () => ({
default: () => ({ isPending: false, data: { balance: 100, monthly_balance: 100, purchased_balance: 0, monthly_allowance: 100, packs: [] } }),
}));
vi.mock("@/lib/api/hooks/app/subscription/useCreditSettings", () => ({ useCreditSettings: () => ({ data: {} }) }));
vi.mock("@/lib/api/hooks/app/subscription/useCreditUsage", () => ({ default: () => ({ isPending: false }) }));
vi.mock("@/components/ui/AnimatedNumber", () => ({ default: ({ value }: { value: number }) => <span>{value}</span> }));
vi.mock("@/components/ui/dither", () => ({ DitherMeter: () => <div /> }));
beforeEach(() => {
fixture.subscription.data = { managed: true, status: "incomplete", plan: { id: "00000000-0000-0000-0000-0000000000e1", name: "Test" } };
useAppStore.setState({ currentOrganization: { id: "review", name: "Reviewer", category: "test", role: "owner" } });
});
afterEach(() => useAppStore.setState({ currentOrganization: null }));
describe("dedicated tester header", () => {
it("shows Test instead of a customer plan, even after the grant expires", () => {
fixture.subscription.data.managed = false;
fixture.subscription.data.plan.name = "Free";
render(<PlanPill />);
expect(screen.getByRole("link", { name: "Test" })).toHaveAttribute("title", "Dedicated tester workspace");
expect(screen.queryByText("Free")).not.toBeInTheDocument();
});
it("labels the bounded allowance as test credits in the header and popover", () => {
render(<CreditsMeter />);
const button = screen.getByRole("button", { name: "100 of 100 test credits left" });
expect(button).toHaveTextContent("Test credits");
fireEvent.click(button);
expect(screen.queryByText("Plan credits")).not.toBeInTheDocument();
expect(screen.getAllByText("Test credits").length).toBeGreaterThan(1);
});
it("leaves a customer workspace's plan and credit labels unchanged", () => {
useAppStore.setState({ currentOrganization: { id: "customer", name: "Customer", category: "standard", role: "owner" } });
fixture.subscription.data = { managed: false, status: "active", plan: { id: "customer-plan", name: "Starter" } };
render(<><PlanPill /><CreditsMeter /></>);
expect(screen.getByRole("link", { name: "Starter" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "100 of 100 plan credits left" })).toBeInTheDocument();
expect(screen.queryByText("Test")).not.toBeInTheDocument();
});
it("unlocks paid surfaces from an active Test grant, never from the category alone", () => {
const { result, rerender } = renderHook(() => useFeatureAccess());
expect(result.current).toMatchObject({ paid: true, locked: false, hasTeam: true, hasBulkOps: true, hasAdvanced: true, hasWebhooks: true });
fixture.subscription.data.managed = false;
fixture.subscription.data.plan = { id: "free", name: "Free" };
rerender();
expect(result.current).toMatchObject({ paid: false, locked: true, hasTeam: false, hasBulkOps: false, hasAdvanced: false, hasWebhooks: false });
});
});
+5 -1
View File
@@ -26,6 +26,7 @@ import { useAppStore } from "@/stores";
import { PERMISSION_BITS, hasPermission } from "@/lib/permissions";
import {
WARMUP_PLAN_ID,
TEST_PLAN_ID,
getPlan,
isAtLeast,
type PlanID,
@@ -106,7 +107,10 @@ export default function useFeatureAccess(): FeatureAccess {
};
}
const planId = sub.data?.plan?.id === WARMUP_PLAN_ID
const testGrant = sub.data?.managed === true && sub.data.plan?.id === TEST_PLAN_ID;
const planId = testGrant
? "business"
: sub.data?.plan?.id === WARMUP_PLAN_ID
? "warmup"
: ((sub.data?.plan?.name ?? currentOrg?.plan ?? "free").toLowerCase()) as PlanID;
const plan = getPlan(planId).id;
@@ -11,6 +11,7 @@ interface RawMembership {
organization?: {
id: string;
name: string;
category?: Organization["category"];
slug?: string;
avatar?: string;
avatar_url?: string | null;
@@ -36,6 +37,7 @@ export default async function getOrganizations(): Promise<Organization[]> {
.map<Organization>((r) => ({
id: r.organization!.id,
name: r.organization!.name,
category: r.organization!.category,
avatar: r.organization!.avatar,
avatar_url: r.organization!.avatar_url ?? null,
plan: r.organization!.plan,
@@ -1,6 +1,7 @@
export default interface Organization {
id: string
name: string
category?: "standard" | "test"
avatar?: string
// Public URL of the workspace avatar, null/undefined when none is set.
avatar_url?: string | null
+2
View File
@@ -11,6 +11,8 @@
export type PlanID = "free" | "warmup" | "starter" | "grow" | "business" | "enterprise";
export const TEST_PLAN_ID = "00000000-0000-0000-0000-0000000000e1";
export interface PlanDef {
id: PlanID;
label: string;
@@ -3,6 +3,7 @@ import type { StateCreator } from 'zustand'
export interface Organization {
id: string
name: string
category?: "standard" | "test"
avatar?: string
avatar_url?: string | null
plan?: string