diff --git a/app/controllers/developer_apps_controller.rb b/app/controllers/developer_apps_controller.rb index 398cd8c..41e09a6 100644 --- a/app/controllers/developer_apps_controller.rb +++ b/app/controllers/developer_apps_controller.rb @@ -153,6 +153,10 @@ class DeveloperAppsController < ApplicationController permitted << :trust_level end + if policy(@app || Program.new).update_byline? + permitted << :byline + end + if policy(@app || Program.new).update_onboarding_scenario? permitted << :onboarding_scenario end diff --git a/app/frontend/stylesheets/snippets/oauth.scss b/app/frontend/stylesheets/snippets/oauth.scss index dbb19cc..3d8253b 100644 --- a/app/frontend/stylesheets/snippets/oauth.scss +++ b/app/frontend/stylesheets/snippets/oauth.scss @@ -3,12 +3,18 @@ .oauth-consent { max-width: 580px; + .oauth-official-byline { + font-size: 0.875rem; + color: var(--text-muted-strong); + text-align: center; + } + .oauth-permissions { margin: $space-6 0; - + fieldset { margin: 0; - + legend { font-size: 0.875rem; font-weight: 600; @@ -18,6 +24,43 @@ 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 { diff --git a/app/models/program.rb b/app/models/program.rb index c4ca21b..e4377f8 100644 --- a/app/models/program.rb +++ b/app/models/program.rb @@ -33,6 +33,7 @@ class Program < ApplicationRecord audit_field :scopes_array, type: :array, label: "scopes" audit_field :redirect_uris, type: :array, label: "redirect URIs" audit_field :active, type: :boolean + audit_field :byline audit_field :onboarding_scenario, transform: ->(v) { v&.titleize } COLLABORATOR_ACTIVITY_KEYS = %w[ diff --git a/app/policies/program_policy.rb b/app/policies/program_policy.rb index cc1de80..607eb0e 100644 --- a/app/policies/program_policy.rb +++ b/app/policies/program_policy.rb @@ -56,6 +56,10 @@ class ProgramPolicy < ApplicationPolicy end end + def update_byline? + user.can_hq_officialize? || admin? + end + def update_onboarding_scenario? super_admin? end diff --git a/app/views/developer_apps/edit.html.erb b/app/views/developer_apps/edit.html.erb index bc38342..71e75ee 100644 --- a/app/views/developer_apps/edit.html.erb +++ b/app/views/developer_apps/edit.html.erb @@ -114,9 +114,16 @@ <%# === 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? %>

<%= t(".admin_settings_heading", default: "Admin Settings") %>

+ <% if policy(@app).update_byline? %> +
+ <%= f.label :byline, t(".byline") %> + <%= f.text_field :byline, placeholder: t(".byline_placeholder") %> + <%= t(".byline_hint") %> +
+ <% end %> <% if policy(@app).update_onboarding_scenario? %>
<%= f.label :onboarding_scenario, t(".onboarding_scenario") %> diff --git a/app/views/doorkeeper/authorizations/new.html.erb b/app/views/doorkeeper/authorizations/new.html.erb index 79dc777..801042e 100644 --- a/app/views/doorkeeper/authorizations/new.html.erb +++ b/app/views/doorkeeper/authorizations/new.html.erb @@ -22,6 +22,12 @@ + <% if application&.hq_official? %> + + <% end %> + <% if @pre_auth.scopes.count > 0 %> <% scopes = @pre_auth.scopes.map(&:to_s) @@ -31,7 +37,7 @@ %> <% if scope_data.any? || unknown_scopes.any? %> -
+ <% permissions_content = capture do %>
<%= t('.able_to') %>
    @@ -60,11 +66,22 @@ <% end %>
-
+ <% end %> + + <% if application&.hq_official? %> +
+ <%= t('.permissions_details') %> + <%= permissions_content %> +
+ <% else %> +
+ <%= permissions_content %> +
+ <% end %> <% end %> <% end %> - <% auth_label = t('.authorize') %> + <% auth_label = application&.hq_official? ? t('.authorize_official') : t('.authorize') %>
' }" 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 %> <%= hidden_field_tag :client_id, @pre_auth.client.uid, id: nil %> diff --git a/config/locales/en.yml b/config/locales/en.yml index b957169..4cda109 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -260,6 +260,9 @@ en: scopes_not_valid_for_trust_level: not valid for this trust level. scopes_locked_by_higher_permission: "Managed by a higher-permission user:" 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_default: "(default)" onboarding_scenario_hint: Users signing up through this OAuth app will use this onboarding flow @@ -329,8 +332,12 @@ en: new: prompt: Authorize %{client_name} to use your account? 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 authorize: Authorize → + authorize_official: Okay! → banners: community_untrusted: title: Community Application diff --git a/db/analytics_schema.rb b/db/analytics_schema.rb new file mode 100644 index 0000000..3f11b30 --- /dev/null +++ b/db/analytics_schema.rb @@ -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 diff --git a/db/migrate/20260324000001_add_byline_to_oauth_applications.rb b/db/migrate/20260324000001_add_byline_to_oauth_applications.rb new file mode 100644 index 0000000..b096066 --- /dev/null +++ b/db/migrate/20260324000001_add_byline_to_oauth_applications.rb @@ -0,0 +1,5 @@ +class AddBylineToOAuthApplications < ActiveRecord::Migration[8.0] + def change + add_column :oauth_applications, :byline, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 1b74f52..4c54b03 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,9 +10,10 @@ # # 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 enable_extension "pg_catalog.plpgsql" + enable_extension "pg_trgm" enable_extension "pgcrypto" 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 "is_in_workspace", default: false, null: false t.string "slack_dm_channel_id" - t.string "webauthn_id" t.boolean "is_alum", default: 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)" @@ -447,6 +447,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_02_000002) do t.integer "sign_count" t.datetime "created_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 ["identity_id"], name: "index_identity_webauthn_credentials_on_identity_id" 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.bigint "owner_identity_id" t.string "onboarding_scenario" + t.string "byline" 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 ["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" 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_variant_records", "active_storage_blobs", column: "blob_id" 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", "identity_aadhaar_records", column: "aadhaar_record_id" add_foreign_key "verifications", "identity_documents" - add_foreign_key "webauthn_credentials", "identities" end diff --git a/spec/views/doorkeeper/authorizations/new_spec.rb b/spec/views/doorkeeper/authorizations/new_spec.rb index ace5927..1351f6a 100644 --- a/spec/views/doorkeeper/authorizations/new_spec.rb +++ b/spec/views/doorkeeper/authorizations/new_spec.rb @@ -47,7 +47,7 @@ RSpec.describe "doorkeeper/authorizations/new", type: :view 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")) render - expect(rendered).to include(identity.verification_status) + expect(rendered).to include(identity.verification_status.humanize) expect(rendered).to include("YSWS eligible") end @@ -86,6 +86,48 @@ RSpec.describe "doorkeeper/authorizations/new", type: :view do 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("