Add subscription expired empty state for Duo Agent Platform
What does this MR do and why?
Add subscription expired empty state for Duo Agent Platform
When a user's paid Duo Agent Platform subscription (Premium/Ultimate) has expired, display an empty state in the Duo chat panel recommending re-purchase. This is distinct from the existing trial-expired state and covers both GitLab.com (SaaS) and self-managed deployments.
Related work items: https://gitlab.com/gitlab-org/gitlab/-/work_items/585918 https://gitlab.com/gitlab-org/gitlab/-/work_items/594394
References
Screenshots or screen recordings
| .com | SM |
|---|---|
![]() |
![]() |
How to set up and validate locally
Reproduce locally — .com (SaaS)
Prereqs
cd ~/gdk/gitlab
yarn install --frozen-lockfile --ignore-scripts
gdk stop vite && rm -rf tmp/cache/vite && gdk start viteIf vite throws "does not provide an export named X" for portal-vue, pikaday, or vue-functional-data-merge, add them to optimizeDeps.include in vite.config.js (local dev-only, don't commit).
1. Put GDK in SaaS simulation mode
Edit ~/gdk/env.runit:
export GITLAB_SIMULATE_SAAS=1
export CUSTOMER_PORTAL_URL=http://localhost:5000
export GITLAB_LICENSE_MODE=test
export CLOUD_CONNECTOR_SELF_SIGN_TOKENS=1gdk restart rails-web rails-background-jobs2. Seed a group with an expired paid subscription
cd ~/gdk/gitlab
GITLAB_SIMULATE_SAAS=1 bundle exec rails runner '
user = User.find_by!(username: "root")
org = Organizations::Organization.default_organization
group = Group.find_or_create_by!(path: "dap-subexp-dotcom") do |g|
g.name = "DAP SubExp DotCom"
g.organization = org
g.owner = user
g.visibility_level = Gitlab::VisibilityLevel::PRIVATE
end
group.add_owner(user) unless group.owners.include?(user)
premium = Plan.find_by(name: "premium") || Plan.find_or_create_by!(name: "premium") { |p| p.title = "Premium" }
sub = GitlabSubscription.find_or_initialize_by(namespace: group)
sub.assign_attributes(hosted_plan: premium, seats: 10, start_date: 1.year.ago.to_date, end_date: 1.day.ago.to_date, trial: false, trial_starts_on: nil, trial_ends_on: nil)
sub.save!(validate: false)
puts "paid_and_expired? -> #{group.gitlab_subscription.paid_and_expired?}"
'Expect: paid_and_expired? -> true
3. Add a non-admin owner + reset password
GITLAB_SIMULATE_SAAS=1 bundle exec rails runner '
group = Group.find_by(path: "dap-subexp-dotcom")
owner = User.find_by!(username: "carla")
group.add_owner(owner) unless group.owners.include?(owner)
owner.password = "Password1!"
owner.password_confirmation = "Password1!"
owner.password_automatically_set = false
owner.confirmed_at ||= Time.current
owner.locked_at = nil
owner.failed_attempts = 0
owner.save!(validate: false)
owner.add_identity_verification_exemption("local dev") unless owner.identity_verification_exempt?
puts "owner: carla / Password1!"
'4. Verify the orchestrator
GITLAB_SIMULATE_SAAS=1 bundle exec rails runner '
group = Group.find_by(path: "dap-subexp-dotcom")
owner = User.find_by!(username: "carla")
puts DuoChatPanel::Component.new(user: owner, project: nil, group: group).send(:component_instance).class.name
'Expect: DuoChatPanel::SubscriptionExpiredComponent
5. See it in the browser
- Incognito window → http://127.0.0.1:3000 → sign in as
carla/Password1! - Go to http://127.0.0.1:3000/groups/dap-subexp-dotcom
- Duo rail opens on the right
- Expect:
- Headline: Your GitLab Duo Agent Platform subscription has been cancelled
- Body: Your subscription has been cancelled. Repurchase to resume using GitLab Duo Agent Platform.
- Primary CTA: Repurchase Agent Platform → subscription portal URL with
plan_id=<premium>andgl_namespace_id=<group.id> - Secondary CTA: Learn more
6. Revert
sed -i '' 's/export GITLAB_SIMULATE_SAAS=1/export GITLAB_SIMULATE_SAAS=0/' ~/gdk/env.runit
gdk restart rails-web rails-background-jobs
cd ~/gdk/gitlab
GITLAB_SIMULATE_SAAS=1 bundle exec rails runner '
g = Group.find_by(path: "dap-subexp-dotcom")
g&.gitlab_subscription&.destroy
g&.destroy
'Reproduce locally — Self-Managed
Prereqs
cd ~/gdk/gitlab
yarn install --frozen-lockfile --ignore-scripts
gdk stop vite && rm -rf tmp/cache/vite && gdk start viteIf vite throws "does not provide an export named X" for portal-vue, pikaday, or vue-functional-data-merge, add them to optimizeDeps.include in vite.config.js (local dev-only, don't commit).
CDot must be cloned at ~/customers-gitlab-com with safe/license_encryption_key present.
1. Put GDK in SM mode
Edit ~/gdk/env.runit:
export GITLAB_SIMULATE_SAAS=0
export CUSTOMER_PORTAL_URL=http://localhost:5000
export GITLAB_LICENSE_MODE=test
export CLOUD_CONNECTOR_SELF_SIGN_TOKENS=1gdk restart rails-web rails-background-jobs2. Mint a non-trial expired license in CDot
cd ~/customers-gitlab-com
bundle exec rails runner '
license = Gitlab::License.new
license.licensee = { "Name" => "Subscription Expired Test", "Company" => "DAP Validation", "Email" => "subexp-sm@example.com" }
license.starts_at = Date.today - 30
license.expires_at = Date.yesterday
license.restrictions = { active_user_count: 100, plan: "ultimate", trial: false }
puts "---LICENSE_BEGIN---"
puts license.export
puts "---LICENSE_END---"
'Copy the multi-line base64 blob between the markers.
3. Import the license into GDK
cd ~/gdk/gitlab
bundle exec rails runner '
key = <<~KEY.delete("\n")
<paste blob here>
KEY
License.where.not(id: nil).delete_all
License.reset_current
License.new(data: key, cloud: false, last_synced_at: Time.current).save!(validate: false)
License.reset_current
cur = License.current
puts "trial?: #{cur.trial?} expired?: #{cur.expired?} plan: #{cur.plan}"
'Expect: trial?: false expired?: true plan: ultimate
4. See it in the browser
- Incognito window → http://127.0.0.1:3000 → sign in as
root - Go to any group or project
- Click the Duo icon on the right rail
- Expect:
- Headline: Your GitLab Duo Agent Platform subscription has been cancelled
- Body: Your subscription has been cancelled. Repurchase to resume using GitLab Duo Agent Platform.
- Primary CTA: Repurchase Agent Platform →
https://customers.staging.gitlab.com - Secondary CTA: Learn more
5. Revert
cd ~/gdk/gitlab
bundle exec rails runner 'License.where.not(id: nil).delete_all; License.reset_current'Automate .com setup via Claude Code — paste this whole prompt
If you have Claude Code installed, paste the following prompt to automate the entire .com setup. You'll only need to open the browser to visually verify.
Set up my local environment to verify MR !232198 — the expired paid subscription empty state in the Duo chat panel on GitLab.com (SaaS simulation).
My GDK is at ~/gdk and GitLab is at ~/gdk/gitlab. Do all steps automatically, stopping only if something fails. Announce each step briefly so I can follow along.
1. Run `cd ~/gdk/gitlab && yarn install --frozen-lockfile --ignore-scripts`.
2. Clear the vite cache and restart it:
```
gdk stop vite
rm -rf ~/gdk/gitlab/tmp/cache/vite
gdk start vite
```
Wait for vite to report "VITE … ready in …ms" in ~/gdk/log/vite/current before proceeding.
3. If you later see vite errors like "does not provide an export named X" for `portal-vue`, `pikaday`, or `vue-functional-data-merge`, add those three packages to `optimizeDeps.include` in `~/gdk/gitlab/vite.config.js` as a local-only change (do not commit), then restart vite.
4. Edit `~/gdk/env.runit` so it contains exactly these lines (preserve other lines if present but set these values):
```
export GITLAB_SIMULATE_SAAS=1
export CUSTOMER_PORTAL_URL=http://localhost:5000
export GITLAB_LICENSE_MODE=test
export CLOUD_CONNECTOR_SELF_SIGN_TOKENS=1
```
5. Run `gdk restart rails-web rails-background-jobs`.
6. From `~/gdk/gitlab`, seed a group with an expired paid subscription using this rails runner (inline env var needed because env.runit is runit-only):
```
GITLAB_SIMULATE_SAAS=1 bundle exec rails runner '
user = User.find_by!(username: "root")
org = Organizations::Organization.default_organization
group = Group.find_or_create_by!(path: "dap-subexp-dotcom") do |g|
g.name = "DAP SubExp DotCom"
g.organization = org
g.owner = user
g.visibility_level = Gitlab::VisibilityLevel::PRIVATE
end
group.add_owner(user) unless group.owners.include?(user)
premium = Plan.find_by(name: "premium") || Plan.find_or_create_by!(name: "premium") { |p| p.title = "Premium" }
sub = GitlabSubscription.find_or_initialize_by(namespace: group)
sub.assign_attributes(hosted_plan: premium, seats: 10, start_date: 1.year.ago.to_date, end_date: 1.day.ago.to_date, trial: false, trial_starts_on: nil, trial_ends_on: nil)
sub.save!(validate: false)
puts "paid_and_expired? -> #{group.gitlab_subscription.paid_and_expired?}"
'
```
Verify the output says `paid_and_expired? -> true`.
7. Add carla as a non-admin group owner with a known password:
```
GITLAB_SIMULATE_SAAS=1 bundle exec rails runner '
group = Group.find_by(path: "dap-subexp-dotcom")
owner = User.find_by!(username: "carla")
group.add_owner(owner) unless group.owners.include?(owner)
owner.password = "Password1!"
owner.password_confirmation = "Password1!"
owner.password_automatically_set = false
owner.confirmed_at ||= Time.current
owner.locked_at = nil
owner.failed_attempts = 0
owner.save!(validate: false)
owner.add_identity_verification_exemption("local dev") unless owner.identity_verification_exempt?
'
```
8. Verify the orchestrator routes to the new component:
```
GITLAB_SIMULATE_SAAS=1 bundle exec rails runner '
group = Group.find_by(path: "dap-subexp-dotcom")
owner = User.find_by!(username: "carla")
puts DuoChatPanel::Component.new(user: owner, project: nil, group: group).send(:component_instance).class.name
'
```
Stop and report if the output is anything other than `DuoChatPanel::SubscriptionExpiredComponent`.
9. Tell me to open an incognito browser, go to http://127.0.0.1:3000/users/sign_in, sign in as `carla` / `Password1!`, then navigate to http://127.0.0.1:3000/groups/dap-subexp-dotcom and expand the Duo rail on the right.
Expected UI:
- Headline: **Your GitLab Duo Agent Platform subscription has been cancelled**
- Body: *Your subscription has been cancelled. Repurchase to resume using GitLab Duo Agent Platform.*
- Primary CTA: **Repurchase Agent Platform** → subscription portal URL with `plan_id=<premium>` and `gl_namespace_id=<group.id>`
- Secondary CTA: **Learn more**Automate SM setup via Claude Code — paste this whole prompt
If you have Claude Code installed, paste the following prompt to automate the entire Self-Managed setup. You'll only need to open the browser to visually verify.
Set up my local environment to verify MR !232198 — the expired paid subscription empty state in the Duo chat panel on self-managed GitLab.
My GDK is at ~/gdk, GitLab is at ~/gdk/gitlab, and CustomersDot is cloned at ~/customers-gitlab-com with `safe/license_encryption_key` already present. Do all steps automatically, stopping only if something fails. Announce each step briefly so I can follow along.
1. Run `cd ~/gdk/gitlab && yarn install --frozen-lockfile --ignore-scripts`.
2. Clear the vite cache and restart it:
```
gdk stop vite
rm -rf ~/gdk/gitlab/tmp/cache/vite
gdk start vite
```
Wait for vite to report "VITE … ready in …ms" in ~/gdk/log/vite/current before proceeding.
3. If you later see vite errors like "does not provide an export named X" for `portal-vue`, `pikaday`, or `vue-functional-data-merge`, add those three packages to `optimizeDeps.include` in `~/gdk/gitlab/vite.config.js` as a local-only change (do not commit), then restart vite.
4. Edit `~/gdk/env.runit` so it contains exactly these lines (preserve other lines if present but set these values):
```
export GITLAB_SIMULATE_SAAS=0
export CUSTOMER_PORTAL_URL=http://localhost:5000
export GITLAB_LICENSE_MODE=test
export CLOUD_CONNECTOR_SELF_SIGN_TOKENS=1
```
5. Run `gdk restart rails-web rails-background-jobs`.
6. From `~/customers-gitlab-com`, mint a signed non-trial expired ultimate license:
```
cd ~/customers-gitlab-com
bundle exec rails runner '
license = Gitlab::License.new
license.licensee = { "Name" => "Subscription Expired Test", "Company" => "DAP Validation", "Email" => "subexp-sm@example.com" }
license.starts_at = Date.today - 30
license.expires_at = Date.yesterday
license.restrictions = { active_user_count: 100, plan: "ultimate", trial: false }
puts "---LICENSE_BEGIN---"
puts license.export
puts "---LICENSE_END---"
'
```
Capture the multi-line base64 blob printed between the markers. You will need it in the next step.
7. From `~/gdk/gitlab`, delete any existing licenses and import the blob you just captured. Substitute the blob into the `<<~KEY` heredoc:
```
cd ~/gdk/gitlab
bundle exec rails runner '
key = <<~KEY.delete("\n")
<paste the blob exactly as captured, preserving line breaks>
KEY
License.where.not(id: nil).delete_all
License.reset_current
License.new(data: key, cloud: false, last_synced_at: Time.current).save!(validate: false)
License.reset_current
cur = License.current
puts "trial?: #{cur.trial?} expired?: #{cur.expired?} plan: #{cur.plan}"
'
```
Stop and report if the output is not `trial?: false expired?: true plan: ultimate`.
8. Tell me to open an incognito browser, go to http://127.0.0.1:3000/users/sign_in, sign in as `root` with my existing root password, then navigate to any project or group (e.g. http://127.0.0.1:3000/groups/dap-subexp-dotcom if it exists) and expand the Duo rail on the right.
Expected UI:
- Headline: **Your GitLab Duo Agent Platform subscription has been cancelled**
- Body: *Your subscription has been cancelled. Repurchase to resume using GitLab Duo Agent Platform.*
- Primary CTA: **Repurchase Agent Platform** → `https://customers.staging.gitlab.com`
- Secondary CTA: **Learn more**MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.
Related to #585918 , #594394

