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
Screenshot_2026-04-20_at_5.25.43_PM Screenshot_2026-04-20_at_8.44.30_PM

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 vite

If 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=1
gdk restart rails-web rails-background-jobs

2. 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

  1. Incognito window → http://127.0.0.1:3000 → sign in as carla / Password1!
  2. Go to http://127.0.0.1:3000/groups/dap-subexp-dotcom
  3. Duo rail opens on the right
  4. 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> and gl_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 vite

If 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=1
gdk restart rails-web rails-background-jobs

2. 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

  1. Incognito window → http://127.0.0.1:3000 → sign in as root
  2. Go to any group or project
  3. Click the Duo icon on the right rail
  4. 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 Platformhttps://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

Edited by Anas Shahid

Merge request reports

Loading