mirror of
https://github.com/System-End/identity-vault.git
synced 2026-04-19 18:35:13 +00:00
less scary consent screen for HQ stuff! (#208)
This commit is contained in:
parent
e007096005
commit
aba5b912e3
11 changed files with 193 additions and 22 deletions
|
|
@ -153,6 +153,10 @@ class DeveloperAppsController < ApplicationController
|
||||||
permitted << :trust_level
|
permitted << :trust_level
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if policy(@app || Program.new).update_byline?
|
||||||
|
permitted << :byline
|
||||||
|
end
|
||||||
|
|
||||||
if policy(@app || Program.new).update_onboarding_scenario?
|
if policy(@app || Program.new).update_onboarding_scenario?
|
||||||
permitted << :onboarding_scenario
|
permitted << :onboarding_scenario
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,18 @@
|
||||||
.oauth-consent {
|
.oauth-consent {
|
||||||
max-width: 580px;
|
max-width: 580px;
|
||||||
|
|
||||||
|
.oauth-official-byline {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-muted-strong);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.oauth-permissions {
|
.oauth-permissions {
|
||||||
margin: $space-6 0;
|
margin: $space-6 0;
|
||||||
|
|
||||||
fieldset {
|
fieldset {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
legend {
|
legend {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
@ -18,6 +24,43 @@
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// collapsed variant for hq official apps
|
||||||
|
&[open] summary {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
> summary {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted-strong);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
list-style-type: none;
|
||||||
|
|
||||||
|
&::-webkit-details-marker,
|
||||||
|
&::marker {
|
||||||
|
display: none;
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "›";
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[open] summary::before {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.permissions-list {
|
.permissions-list {
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ class Program < ApplicationRecord
|
||||||
audit_field :scopes_array, type: :array, label: "scopes"
|
audit_field :scopes_array, type: :array, label: "scopes"
|
||||||
audit_field :redirect_uris, type: :array, label: "redirect URIs"
|
audit_field :redirect_uris, type: :array, label: "redirect URIs"
|
||||||
audit_field :active, type: :boolean
|
audit_field :active, type: :boolean
|
||||||
|
audit_field :byline
|
||||||
audit_field :onboarding_scenario, transform: ->(v) { v&.titleize }
|
audit_field :onboarding_scenario, transform: ->(v) { v&.titleize }
|
||||||
|
|
||||||
COLLABORATOR_ACTIVITY_KEYS = %w[
|
COLLABORATOR_ACTIVITY_KEYS = %w[
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,10 @@ class ProgramPolicy < ApplicationPolicy
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def update_byline?
|
||||||
|
user.can_hq_officialize? || admin?
|
||||||
|
end
|
||||||
|
|
||||||
def update_onboarding_scenario?
|
def update_onboarding_scenario?
|
||||||
super_admin?
|
super_admin?
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -114,9 +114,16 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%# === Card 4: Admin Settings (if applicable) === %>
|
<%# === Card 4: Admin Settings (if applicable) === %>
|
||||||
<% if policy(@app).update_onboarding_scenario? || policy(@app).update_active? %>
|
<% if policy(@app).update_byline? || policy(@app).update_onboarding_scenario? || policy(@app).update_active? %>
|
||||||
<section class="section-card">
|
<section class="section-card">
|
||||||
<h3><%= t(".admin_settings_heading", default: "Admin Settings") %></h3>
|
<h3><%= t(".admin_settings_heading", default: "Admin Settings") %></h3>
|
||||||
|
<% if policy(@app).update_byline? %>
|
||||||
|
<div class="field-group">
|
||||||
|
<%= f.label :byline, t(".byline") %>
|
||||||
|
<%= f.text_field :byline, placeholder: t(".byline_placeholder") %>
|
||||||
|
<small class="usn"><%= t(".byline_hint") %></small>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
<% if policy(@app).update_onboarding_scenario? %>
|
<% if policy(@app).update_onboarding_scenario? %>
|
||||||
<div class="field-group">
|
<div class="field-group">
|
||||||
<%= f.label :onboarding_scenario, t(".onboarding_scenario") %>
|
<%= f.label :onboarding_scenario, t(".onboarding_scenario") %>
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,12 @@
|
||||||
</small>
|
</small>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<% if application&.hq_official? %>
|
||||||
|
<div class="oauth-official-byline">
|
||||||
|
<%= t('.official_byline', byline: application.byline.presence || t('.official_byline_default')) %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<% if @pre_auth.scopes.count > 0 %>
|
<% if @pre_auth.scopes.count > 0 %>
|
||||||
<%
|
<%
|
||||||
scopes = @pre_auth.scopes.map(&:to_s)
|
scopes = @pre_auth.scopes.map(&:to_s)
|
||||||
|
|
@ -31,7 +37,7 @@
|
||||||
%>
|
%>
|
||||||
|
|
||||||
<% if scope_data.any? || unknown_scopes.any? %>
|
<% if scope_data.any? || unknown_scopes.any? %>
|
||||||
<div class="oauth-permissions">
|
<% permissions_content = capture do %>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend><%= t('.able_to') %></legend>
|
<legend><%= t('.able_to') %></legend>
|
||||||
<ul class="permissions-list">
|
<ul class="permissions-list">
|
||||||
|
|
@ -60,11 +66,22 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
</ul>
|
</ul>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
<% end %>
|
||||||
|
|
||||||
|
<% if application&.hq_official? %>
|
||||||
|
<details class="oauth-permissions">
|
||||||
|
<summary><%= t('.permissions_details') %></summary>
|
||||||
|
<%= permissions_content %>
|
||||||
|
</details>
|
||||||
|
<% else %>
|
||||||
|
<div class="oauth-permissions">
|
||||||
|
<%= permissions_content %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% auth_label = t('.authorize') %>
|
<% auth_label = application&.hq_official? ? t('.authorize_official') : t('.authorize') %>
|
||||||
<div class="oauth-actions" x-data="{ countdown: <%= application&.hq_official? ? 0 : 3 %>, ready: <%= application&.hq_official? ? true : false %>, authLabel: '<%= auth_label.gsub("'", "\\'") %>' }" x-init="if (countdown > 0) { let interval = setInterval(() => { countdown--; if (countdown <= 0) { ready = true; clearInterval(interval); } }, 1000); }">
|
<div class="oauth-actions" x-data="{ countdown: <%= application&.hq_official? ? 0 : 3 %>, ready: <%= application&.hq_official? ? true : false %>, authLabel: '<%= auth_label.gsub("'", "\\'") %>' }" x-init="if (countdown > 0) { let interval = setInterval(() => { countdown--; if (countdown <= 0) { ready = true; clearInterval(interval); } }, 1000); }">
|
||||||
<%= form_tag oauth_authorization_path, method: :delete do %>
|
<%= form_tag oauth_authorization_path, method: :delete do %>
|
||||||
<%= hidden_field_tag :client_id, @pre_auth.client.uid, id: nil %>
|
<%= hidden_field_tag :client_id, @pre_auth.client.uid, id: nil %>
|
||||||
|
|
|
||||||
|
|
@ -260,6 +260,9 @@ en:
|
||||||
scopes_not_valid_for_trust_level: not valid for this trust level.
|
scopes_not_valid_for_trust_level: not valid for this trust level.
|
||||||
scopes_locked_by_higher_permission: "Managed by a higher-permission user:"
|
scopes_locked_by_higher_permission: "Managed by a higher-permission user:"
|
||||||
admin_settings_heading: Admin Settings
|
admin_settings_heading: Admin Settings
|
||||||
|
byline: Consent Screen Byline
|
||||||
|
byline_hint: Shown on the consent screen for HQ official apps. Leave blank to default to "Hack Club HQ".
|
||||||
|
byline_placeholder: "@nora, @msw, @zrl"
|
||||||
onboarding_scenario: Onboarding Scenario
|
onboarding_scenario: Onboarding Scenario
|
||||||
onboarding_default: "(default)"
|
onboarding_default: "(default)"
|
||||||
onboarding_scenario_hint: Users signing up through this OAuth app will use this onboarding flow
|
onboarding_scenario_hint: Users signing up through this OAuth app will use this onboarding flow
|
||||||
|
|
@ -329,8 +332,12 @@ en:
|
||||||
new:
|
new:
|
||||||
prompt: Authorize %{client_name} to use your account?
|
prompt: Authorize %{client_name} to use your account?
|
||||||
able_to: "This application will be able to:"
|
able_to: "This application will be able to:"
|
||||||
|
official_byline: "This is an official Hack Club program run by %{byline}."
|
||||||
|
official_byline_default: Hack Club HQ
|
||||||
|
permissions_details: What data will be shared?
|
||||||
deny: Deny
|
deny: Deny
|
||||||
authorize: Authorize →
|
authorize: Authorize →
|
||||||
|
authorize_official: Okay! →
|
||||||
banners:
|
banners:
|
||||||
community_untrusted:
|
community_untrusted:
|
||||||
title: Community Application
|
title: Community Application
|
||||||
|
|
|
||||||
52
db/analytics_schema.rb
Normal file
52
db/analytics_schema.rb
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
# This file is auto-generated from the current state of the database. Instead
|
||||||
|
# of editing this file, please use the migrations feature of Active Record to
|
||||||
|
# incrementally modify your database, and then regenerate this schema definition.
|
||||||
|
#
|
||||||
|
# This file is the source Rails uses to define your schema when running `bin/rails
|
||||||
|
# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
|
||||||
|
# be faster and is potentially less error prone than running all of your
|
||||||
|
# migrations from scratch. Old migrations may fail to apply correctly if those
|
||||||
|
# migrations use external dependencies or application code.
|
||||||
|
#
|
||||||
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
|
ActiveRecord::Schema[8.0].define(version: 2026_01_12_000002) do
|
||||||
|
# These are extensions that must be enabled in order to support this database
|
||||||
|
enable_extension "pg_catalog.plpgsql"
|
||||||
|
|
||||||
|
create_table "ahoy_events", force: :cascade do |t|
|
||||||
|
t.bigint "visit_id"
|
||||||
|
t.string "name"
|
||||||
|
t.jsonb "properties"
|
||||||
|
t.datetime "time"
|
||||||
|
t.index ["name", "time"], name: "index_ahoy_events_on_name_and_time"
|
||||||
|
t.index ["name"], name: "index_ahoy_events_on_name"
|
||||||
|
t.index ["properties"], name: "index_ahoy_events_on_properties", using: :gin
|
||||||
|
t.index ["time"], name: "index_ahoy_events_on_time"
|
||||||
|
t.index ["visit_id"], name: "index_ahoy_events_on_visit_id"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "ahoy_visits", force: :cascade do |t|
|
||||||
|
t.string "visit_token"
|
||||||
|
t.string "visitor_token"
|
||||||
|
t.string "ip"
|
||||||
|
t.text "user_agent"
|
||||||
|
t.text "referrer"
|
||||||
|
t.string "referring_domain"
|
||||||
|
t.text "landing_page"
|
||||||
|
t.string "browser"
|
||||||
|
t.string "os"
|
||||||
|
t.string "device_type"
|
||||||
|
t.string "utm_source"
|
||||||
|
t.string "utm_medium"
|
||||||
|
t.string "utm_campaign"
|
||||||
|
t.string "utm_term"
|
||||||
|
t.string "utm_content"
|
||||||
|
t.datetime "started_at"
|
||||||
|
t.index ["started_at"], name: "index_ahoy_visits_on_started_at"
|
||||||
|
t.index ["visit_token"], name: "index_ahoy_visits_on_visit_token", unique: true
|
||||||
|
t.index ["visitor_token"], name: "index_ahoy_visits_on_visitor_token"
|
||||||
|
end
|
||||||
|
|
||||||
|
add_foreign_key "ahoy_events", "ahoy_visits", column: "visit_id"
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
class AddBylineToOAuthApplications < ActiveRecord::Migration[8.0]
|
||||||
|
def change
|
||||||
|
add_column :oauth_applications, :byline, :string
|
||||||
|
end
|
||||||
|
end
|
||||||
19
db/schema.rb
generated
19
db/schema.rb
generated
|
|
@ -10,9 +10,10 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.0].define(version: 2026_03_02_000002) do
|
ActiveRecord::Schema[8.0].define(version: 2026_03_24_000001) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pg_catalog.plpgsql"
|
enable_extension "pg_catalog.plpgsql"
|
||||||
|
enable_extension "pg_trgm"
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
|
|
||||||
create_table "active_storage_attachments", force: :cascade do |t|
|
create_table "active_storage_attachments", force: :cascade do |t|
|
||||||
|
|
@ -301,7 +302,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_02_000002) do
|
||||||
t.boolean "saml_debug"
|
t.boolean "saml_debug"
|
||||||
t.boolean "is_in_workspace", default: false, null: false
|
t.boolean "is_in_workspace", default: false, null: false
|
||||||
t.string "slack_dm_channel_id"
|
t.string "slack_dm_channel_id"
|
||||||
t.string "webauthn_id"
|
|
||||||
t.boolean "is_alum", default: false
|
t.boolean "is_alum", default: false
|
||||||
t.boolean "can_hq_officialize", default: false, null: false
|
t.boolean "can_hq_officialize", default: false, null: false
|
||||||
t.index "lower((primary_email)::text)", name: "idx_identities_unique_primary_email", unique: true, where: "(deleted_at IS NULL)"
|
t.index "lower((primary_email)::text)", name: "idx_identities_unique_primary_email", unique: true, where: "(deleted_at IS NULL)"
|
||||||
|
|
@ -447,6 +447,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_02_000002) do
|
||||||
t.integer "sign_count"
|
t.integer "sign_count"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
|
t.datetime "compromised_at"
|
||||||
t.index ["external_id"], name: "index_identity_webauthn_credentials_on_external_id", unique: true
|
t.index ["external_id"], name: "index_identity_webauthn_credentials_on_external_id", unique: true
|
||||||
t.index ["identity_id"], name: "index_identity_webauthn_credentials_on_identity_id"
|
t.index ["identity_id"], name: "index_identity_webauthn_credentials_on_identity_id"
|
||||||
end
|
end
|
||||||
|
|
@ -516,6 +517,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_02_000002) do
|
||||||
t.integer "trust_level", default: 0, null: false
|
t.integer "trust_level", default: 0, null: false
|
||||||
t.bigint "owner_identity_id"
|
t.bigint "owner_identity_id"
|
||||||
t.string "onboarding_scenario"
|
t.string "onboarding_scenario"
|
||||||
|
t.string "byline"
|
||||||
t.index ["owner_identity_id"], name: "index_oauth_applications_on_owner_identity_id"
|
t.index ["owner_identity_id"], name: "index_oauth_applications_on_owner_identity_id"
|
||||||
t.index ["program_key_bidx"], name: "index_oauth_applications_on_program_key_bidx", unique: true
|
t.index ["program_key_bidx"], name: "index_oauth_applications_on_program_key_bidx", unique: true
|
||||||
t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true
|
t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true
|
||||||
|
|
@ -602,18 +604,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_02_000002) do
|
||||||
t.index ["item_type", "item_id"], name: "index_versions_on_item_type_and_item_id"
|
t.index ["item_type", "item_id"], name: "index_versions_on_item_type_and_item_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "webauthn_credentials", force: :cascade do |t|
|
|
||||||
t.bigint "identity_id", null: false
|
|
||||||
t.string "external_id", null: false
|
|
||||||
t.string "public_key", null: false
|
|
||||||
t.string "nickname", null: false
|
|
||||||
t.integer "sign_count", default: 0, null: false
|
|
||||||
t.datetime "created_at", null: false
|
|
||||||
t.datetime "updated_at", null: false
|
|
||||||
t.index ["external_id"], name: "index_webauthn_credentials_on_external_id", unique: true
|
|
||||||
t.index ["identity_id"], name: "index_webauthn_credentials_on_identity_id"
|
|
||||||
end
|
|
||||||
|
|
||||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||||
add_foreign_key "addresses", "identities"
|
add_foreign_key "addresses", "identities"
|
||||||
|
|
@ -648,5 +638,4 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_02_000002) do
|
||||||
add_foreign_key "verifications", "identities"
|
add_foreign_key "verifications", "identities"
|
||||||
add_foreign_key "verifications", "identity_aadhaar_records", column: "aadhaar_record_id"
|
add_foreign_key "verifications", "identity_aadhaar_records", column: "aadhaar_record_id"
|
||||||
add_foreign_key "verifications", "identity_documents"
|
add_foreign_key "verifications", "identity_documents"
|
||||||
add_foreign_key "webauthn_credentials", "identities"
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ RSpec.describe "doorkeeper/authorizations/new", type: :view do
|
||||||
it "shows verification status for verification_status scope" do
|
it "shows verification status for verification_status scope" do
|
||||||
allow(pre_auth).to receive(:scopes).and_return(Doorkeeper::OAuth::Scopes.from_string("verification_status"))
|
allow(pre_auth).to receive(:scopes).and_return(Doorkeeper::OAuth::Scopes.from_string("verification_status"))
|
||||||
render
|
render
|
||||||
expect(rendered).to include(identity.verification_status)
|
expect(rendered).to include(identity.verification_status.humanize)
|
||||||
expect(rendered).to include("YSWS eligible")
|
expect(rendered).to include("YSWS eligible")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -86,6 +86,48 @@ RSpec.describe "doorkeeper/authorizations/new", type: :view do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "hq official byline" do
|
||||||
|
it "shows default byline for hq_official apps without a custom byline" do
|
||||||
|
allow(program).to receive(:hq_official?).and_return(true)
|
||||||
|
allow(program).to receive(:byline).and_return(nil)
|
||||||
|
render
|
||||||
|
expect(rendered).to include("official Hack Club program")
|
||||||
|
expect(rendered).to include("Hack Club HQ")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "shows custom byline when set" do
|
||||||
|
allow(program).to receive(:hq_official?).and_return(true)
|
||||||
|
allow(program).to receive(:byline).and_return("@nora, @msw, @zrl")
|
||||||
|
render
|
||||||
|
expect(rendered).to include("@nora, @msw, @zrl")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not show byline for community apps" do
|
||||||
|
allow(program).to receive(:hq_official?).and_return(false)
|
||||||
|
allow(program).to receive(:trust_level).and_return("community_untrusted")
|
||||||
|
render
|
||||||
|
expect(rendered).not_to include("official Hack Club program")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "permissions display" do
|
||||||
|
it "collapses permissions behind details for hq_official apps" do
|
||||||
|
allow(program).to receive(:hq_official?).and_return(true)
|
||||||
|
allow(program).to receive(:byline).and_return(nil)
|
||||||
|
render
|
||||||
|
expect(rendered).to include("<details")
|
||||||
|
expect(rendered).to include("What data will be shared?")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "shows permissions directly for non-official apps" do
|
||||||
|
allow(program).to receive(:hq_official?).and_return(false)
|
||||||
|
allow(program).to receive(:trust_level).and_return("community_untrusted")
|
||||||
|
render
|
||||||
|
expect(rendered).not_to include("<details")
|
||||||
|
expect(rendered).to include("oauth-permissions")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "trust level banners" do
|
describe "trust level banners" do
|
||||||
it "shows warning banner for community_untrusted apps" do
|
it "shows warning banner for community_untrusted apps" do
|
||||||
allow(program).to receive(:trust_level).and_return("community_untrusted")
|
allow(program).to receive(:trust_level).and_return("community_untrusted")
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue