From 9d43a2a7ccbb5a98f0960fe8b8428571dc222489 Mon Sep 17 00:00:00 2001 From: 24c02 <163450896+24c02@users.noreply.github.com> Date: Sun, 1 Mar 2026 10:32:03 -0500 Subject: [PATCH 01/30] temp commit --- app/components/sidebar.rb | 4 +- .../backend/identities_controller.rb | 4 +- app/controllers/backend/kbar_controller.rb | 2 +- .../backend/programs_controller.rb | 94 ------ .../concerns/identity_authorizable.rb | 24 ++ .../developer_app_collaborators_controller.rb | 41 +++ app/controllers/developer_apps_controller.rb | 109 +++++- app/frontend/js/alpine.js | 2 + app/frontend/js/scope-editor.js | 49 +++ app/frontend/stylesheets/application.scss | 1 + .../stylesheets/snippets/developer_apps.scss | 310 ++++++++++++++++++ app/lib/shortcodes.rb | 4 +- app/models/identity.rb | 8 + app/models/oauth_scope.rb | 3 + app/models/program.rb | 13 + app/models/program_collaborator.rb | 8 + app/policies/program_policy.rb | 116 +++++-- app/views/backend/identities/edit.html.erb | 5 + app/views/backend/programs/_program.html.erb | 6 - app/views/backend/programs/edit.html.erb | 9 - app/views/backend/programs/index.html.erb | 45 --- app/views/backend/programs/new.html.erb | 9 - app/views/backend/programs/show.html.erb | 95 ------ app/views/backend/static_pages/index.html.erb | 2 +- app/views/developer_apps/edit.html.erb | 139 ++++++-- app/views/developer_apps/index.html.erb | 83 ++--- app/views/developer_apps/new.html.erb | 115 +++++-- app/views/developer_apps/show.html.erb | 197 +++++++---- app/views/forms/backend/programs/form.rb | 78 ----- config/locales/en.yml | 82 ++++- config/routes.rb | 9 +- db/analytics_schema.rb | 52 +++ ...00_add_can_hq_officialize_to_identities.rb | 5 + ...0226200001_create_program_collaborators.rb | 11 + db/schema.rb | 30 +- spec/factories/backend_users.rb | 25 ++ spec/factories/identities.rb | 8 + spec/factories/program_collaborators.rb | 6 + 38 files changed, 1238 insertions(+), 565 deletions(-) delete mode 100644 app/controllers/backend/programs_controller.rb create mode 100644 app/controllers/concerns/identity_authorizable.rb create mode 100644 app/controllers/developer_app_collaborators_controller.rb create mode 100644 app/frontend/js/scope-editor.js create mode 100644 app/frontend/stylesheets/snippets/developer_apps.scss create mode 100644 app/models/program_collaborator.rb delete mode 100644 app/views/backend/programs/_program.html.erb delete mode 100644 app/views/backend/programs/edit.html.erb delete mode 100644 app/views/backend/programs/index.html.erb delete mode 100644 app/views/backend/programs/new.html.erb delete mode 100644 app/views/backend/programs/show.html.erb delete mode 100644 app/views/forms/backend/programs/form.rb create mode 100644 db/analytics_schema.rb create mode 100644 db/migrate/20260226200000_add_can_hq_officialize_to_identities.rb create mode 100644 db/migrate/20260226200001_create_program_collaborators.rb create mode 100644 spec/factories/backend_users.rb create mode 100644 spec/factories/program_collaborators.rb diff --git a/app/components/sidebar.rb b/app/components/sidebar.rb index ec42f6b8..6ee49383 100644 --- a/app/components/sidebar.rb +++ b/app/components/sidebar.rb @@ -50,8 +50,8 @@ def nav_items items << { label: t("sidebar.addresses"), path: addresses_path, icon: "email" } items << { label: t("sidebar.security"), path: security_path, icon: "private" } - # Add developer link if developer mode is enabled - if current_identity.present? && current_identity.developer_mode? + # Add developer link if developer mode is enabled or user is a program manager/super admin + if current_identity.present? && (current_identity.developer_mode? || current_identity.backend_user&.program_manager? || current_identity.backend_user&.super_admin?) items << { label: t("sidebar.developer"), path: developer_apps_path, icon: "code" } end diff --git a/app/controllers/backend/identities_controller.rb b/app/controllers/backend/identities_controller.rb index 5051e000..7e2d919c 100644 --- a/app/controllers/backend/identities_controller.rb +++ b/app/controllers/backend/identities_controller.rb @@ -182,7 +182,9 @@ def set_identity end def identity_params - params.require(:identity).permit(:first_name, :last_name, :legal_first_name, :legal_last_name, :primary_email, :phone_number, :birthday, :country, :hq_override, :ysws_eligible, :permabanned) + permitted = [ :first_name, :last_name, :legal_first_name, :legal_last_name, :primary_email, :phone_number, :birthday, :country, :hq_override, :ysws_eligible, :permabanned ] + permitted << :can_hq_officialize if current_user&.super_admin? + params.require(:identity).permit(permitted) end def vouch_params diff --git a/app/controllers/backend/kbar_controller.rb b/app/controllers/backend/kbar_controller.rb index 4b5c48b4..4eec40b0 100644 --- a/app/controllers/backend/kbar_controller.rb +++ b/app/controllers/backend/kbar_controller.rb @@ -60,7 +60,7 @@ def search_in_scope(scope, query) id: app.id, label: app.name, sublabel: app.redirect_uri&.truncate(50), - path: "/backend/programs/#{app.id}" + path: "/developer/apps/#{app.id}" } end end diff --git a/app/controllers/backend/programs_controller.rb b/app/controllers/backend/programs_controller.rb deleted file mode 100644 index 0f77c730..00000000 --- a/app/controllers/backend/programs_controller.rb +++ /dev/null @@ -1,94 +0,0 @@ -class Backend::ProgramsController < Backend::ApplicationController - before_action :set_program, only: [ :show, :edit, :update, :destroy, :rotate_credentials ] - - hint :list_navigation, on: :index - hint :back_navigation, on: :index - - def index - authorize Program - - set_keyboard_shortcut(:back, backend_root_path) - - @programs = policy_scope(Program).includes(:identities).order(:name) - end - - def show - authorize @program - @identities_count = @program.identities.distinct.count - end - - def new - @program = Program.new - authorize @program - end - - def create - @program = Program.new(program_params) - authorize @program - - if params[:oauth_application] && params[:oauth_application][:redirect_uri].present? - @program.redirect_uri = params[:oauth_application][:redirect_uri] - end - - if @program.save - redirect_to backend_program_path(@program), notice: "Program was successfully created." - else - render :new, status: :unprocessable_entity - end - end - - def edit - authorize @program - end - - def update - authorize @program - - if params[:oauth_application] && params[:oauth_application][:redirect_uri].present? - @program.redirect_uri = params[:oauth_application][:redirect_uri] - end - - if @program.update(program_params_for_user) - redirect_to backend_program_path(@program), notice: "Program was successfully updated." - else - render :edit, status: :unprocessable_entity - end - end - - def destroy - authorize @program - @program.destroy - redirect_to backend_programs_path, notice: "Program was successfully deleted." - end - - def rotate_credentials - authorize @program - @program.rotate_credentials! - redirect_to backend_program_path(@program), notice: "Credentials have been rotated. Make sure to update any integrations using the old secret/API key." - end - - - private - - def set_program - @program = Program.find(params[:id]) - end - - def program_params - params.require(:program).permit(:name, :description, :active, scopes_array: []) - end - - def program_params_for_user - permitted_params = [ :name, :redirect_uri ] - - if policy(@program).update_scopes? - permitted_params += [ :description, :active, :trust_level, scopes_array: [] ] - end - - if policy(@program).update_onboarding_scenario? - permitted_params << :onboarding_scenario - end - - params.require(:program).permit(permitted_params) - end -end diff --git a/app/controllers/concerns/identity_authorizable.rb b/app/controllers/concerns/identity_authorizable.rb new file mode 100644 index 00000000..511a4de6 --- /dev/null +++ b/app/controllers/concerns/identity_authorizable.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# Opt-in concern for frontend controllers that want Pundit authorization +# with Identity as the authorization subject (instead of Backend::User). +# +# This does NOT affect backend controllers — they continue using +# Backend::User via Backend::ApplicationController. +module IdentityAuthorizable + extend ActiveSupport::Concern + + included do + include Pundit::Authorization + after_action :verify_authorized + + rescue_from Pundit::NotAuthorizedError do |_e| + flash[:error] = "You're not authorized to do that." + redirect_to root_path + end + + def pundit_user + current_identity + end + end +end diff --git a/app/controllers/developer_app_collaborators_controller.rb b/app/controllers/developer_app_collaborators_controller.rb new file mode 100644 index 00000000..a9371e34 --- /dev/null +++ b/app/controllers/developer_app_collaborators_controller.rb @@ -0,0 +1,41 @@ +class DeveloperAppCollaboratorsController < ApplicationController + include IdentityAuthorizable + + before_action :set_app + + def create + authorize @app, :manage_collaborators? + + email = params[:email].to_s.strip.downcase + + # Anti-enumeration: always return the same generic message regardless of outcome + identity = Identity.find_by(primary_email: email) + + if identity && identity != @app.owner_identity + @app.program_collaborators.find_or_create_by(identity: identity) + end + + redirect_to developer_app_path(@app), notice: t(".generic_response") + end + + def destroy + authorize @app, :manage_collaborators? + + collaborator = @app.program_collaborators.find(params[:id]) + collaborator.destroy + + redirect_to developer_app_path(@app), notice: t(".success") + rescue ActiveRecord::RecordNotFound + flash[:error] = t("developer_apps.collaborator.not_found") + redirect_to developer_app_path(@app) + end + + private + + def set_app + @app = Program.find(params[:developer_app_id]) + rescue ActiveRecord::RecordNotFound + flash[:error] = t("developer_apps.set_app.not_found") + redirect_to developer_apps_path + end +end diff --git a/app/controllers/developer_apps_controller.rb b/app/controllers/developer_apps_controller.rb index 74a486b7..cb70899d 100644 --- a/app/controllers/developer_apps_controller.rb +++ b/app/controllers/developer_apps_controller.rb @@ -1,23 +1,46 @@ class DeveloperAppsController < ApplicationController - before_action :require_developer_mode - before_action :set_app, only: [ :show, :edit, :update, :destroy, :rotate_credentials ] + include IdentityAuthorizable + + before_action :set_app, only: [ :show, :edit, :update, :destroy, :rotate_credentials, :revoke_all_authorizations ] def index - @apps = current_identity.owned_developer_apps.order(created_at: :desc) + authorize Program + + @apps = policy_scope(Program).includes(:owner_identity).order(created_at: :desc) + + if admin? + @apps = @apps.where("oauth_applications.name ILIKE :q OR oauth_applications.uid ILIKE :q", q: "%#{params[:search]}%") if params[:search].present? + if params[:owner_id].present? + @apps = @apps.where(owner_identity_id: params[:owner_id]) + end + end + + @apps = @apps.page(params[:page]).per(25) end def show + authorize @app + @identities_count = @app.identities.distinct.count + @collaborators = @app.program_collaborators.includes(:identity) if policy(@app).manage_collaborators? end def new - @app = Program.new + @app = Program.new(trust_level: :community_untrusted) + authorize @app end def create - @app = Program.new(app_params) - @app.trust_level = :community_untrusted + @app = Program.new(app_params_for_identity) + authorize @app + + unless policy(@app).update_trust_level? + @app.trust_level = :community_untrusted + end @app.owner_identity = current_identity + # Server-side scope enforcement: only allow scopes within this user's tier + enforce_allowed_scopes!(@app, existing_scopes: []) + if @app.save redirect_to developer_app_path(@app), notice: t(".success") else @@ -26,10 +49,19 @@ def create end def edit + authorize @app end def update - if @app.update(app_params) + authorize @app + + existing_scopes = @app.scopes_array.dup + @app.assign_attributes(app_params_for_identity) + + # Server-side scope enforcement: preserve locked scopes, reject unauthorized additions + enforce_allowed_scopes!(@app, existing_scopes: existing_scopes) + + if @app.save redirect_to developer_app_path(@app), notice: t(".success") else render :edit, status: :unprocessable_entity @@ -37,6 +69,7 @@ def update end def destroy + authorize @app app_name = @app.name @app.create_activity :destroy, owner: current_identity, parameters: { name: app_name } @app.destroy @@ -44,27 +77,67 @@ def destroy end def rotate_credentials + authorize @app @app.rotate_credentials! - redirect_to developer_app_path(@app), notice: t(".success") + redirect_to developer_app_path(@app), notice: t(".rotate_credentials.success") end - private - - def require_developer_mode - unless current_identity.developer_mode? - flash[:error] = t(".require_developer_mode") - redirect_to root_path - end + def revoke_all_authorizations + authorize @app + count = @app.access_tokens.update_all(revoked_at: Time.current) + PaperTrail.request.whodunnit = current_identity.id.to_s + @app.paper_trail_event = "revoke_all_authorizations" + @app.paper_trail.save_with_version + redirect_to developer_app_path(@app), notice: t(".revoke_all_authorizations.success", count: count) end + private + def set_app - @app = current_identity.owned_developer_apps.find(params[:id]) + @app = Program.find(params[:id]) rescue ActiveRecord::RecordNotFound flash[:error] = t("developer_apps.set_app.not_found") redirect_to developer_apps_path end - def app_params - params.require(:program).permit(:name, :redirect_uri, scopes_array: []) + # Server-side enforcement: a user can only add/remove scopes within their + # allowed tier. Scopes outside that tier that already exist on the app + # ("locked scopes") are always preserved — a community user editing an + # hq_official app cannot strip `basic_info`, and nobody can inject + # `set_slack_id` via a forged form. + # + # Formula: final = (submitted ∩ allowed) ∪ (existing ∩ ¬allowed) + def enforce_allowed_scopes!(app, existing_scopes:) + allowed = policy(app).allowed_scopes + submitted = app.scopes_array + + user_controlled = submitted & allowed # only keep what they're allowed to touch + locked = existing_scopes - allowed # preserve what they can't touch + + app.scopes_array = (user_controlled + locked).uniq + end + + def app_params_for_identity + permitted = [ :name, :redirect_uri, scopes_array: [] ] + + if policy(@app || Program.new).update_trust_level? + permitted << :trust_level + end + + if policy(@app || Program.new).update_onboarding_scenario? + permitted << :onboarding_scenario + end + + if policy(@app || Program.new).update_active? + permitted << :active + end + + params.require(:program).permit(permitted) + end + + def admin? + backend_user = current_identity.backend_user + backend_user&.program_manager? || backend_user&.super_admin? end + helper_method :admin? end diff --git a/app/frontend/js/alpine.js b/app/frontend/js/alpine.js index 2616eebf..70b01271 100644 --- a/app/frontend/js/alpine.js +++ b/app/frontend/js/alpine.js @@ -2,10 +2,12 @@ import Alpine from 'alpinejs' import { webauthnRegister } from './webauthn-registration.js' import { webauthnAuth } from './webauthn-authentication.js' import { stepUpWebauthn } from './webauthn-step-up.js' +import { scopeEditor } from './scope-editor.js' Alpine.data('webauthnRegister', webauthnRegister) Alpine.data('webauthnAuth', webauthnAuth) Alpine.data('stepUpWebauthn', stepUpWebauthn) +Alpine.data('scopeEditor', scopeEditor) window.Alpine = Alpine Alpine.start() \ No newline at end of file diff --git a/app/frontend/js/scope-editor.js b/app/frontend/js/scope-editor.js new file mode 100644 index 00000000..26b592a6 --- /dev/null +++ b/app/frontend/js/scope-editor.js @@ -0,0 +1,49 @@ +const YSWS_DEFAULT_SCOPES = ['name', 'birthdate', 'address', 'basic_info', 'verification_status']; + +export function scopeEditor({ trustLevel, selectedScopes, allowedScopes, communityScopes, allScopes, yswsDefaults }) { + return { + trustLevel, + selected: [...selectedScopes], + removedScopes: [], + showYswsDefaults: yswsDefaults || false, + + get editableScopes() { + const pool = this.trustLevel === 'hq_official' ? allowedScopes : communityScopes; + return allScopes.filter(s => pool.includes(s.name)); + }, + + // Scopes on the app that this user can't touch — rendered as locked rows + // with hidden inputs so they're preserved on save. + // Uses the *original* selectedScopes (closure over init arg) so a locked + // scope stays locked even if Alpine state is manipulated. + get lockedScopes() { + const editableNames = this.editableScopes.map(s => s.name); + return allScopes.filter(s => + selectedScopes.includes(s.name) && !editableNames.includes(s.name) + ); + }, + + isChecked(name) { return this.selected.includes(name); }, + + toggle(name) { + const i = this.selected.indexOf(name); + if (i >= 0) this.selected.splice(i, 1); + else this.selected.push(name); + }, + + onTrustLevelChange() { + const valid = this.editableScopes.map(s => s.name); + this.removedScopes = this.selected.filter(s => !valid.includes(s)); + this.selected = this.selected.filter(s => valid.includes(s)); + }, + + dismissWarning() { this.removedScopes = []; }, + + applyYswsDefaults() { + this.trustLevel = 'hq_official'; + this.removedScopes = []; + const valid = this.editableScopes.map(s => s.name); + this.selected = YSWS_DEFAULT_SCOPES.filter(s => valid.includes(s)); + }, + }; +} diff --git a/app/frontend/stylesheets/application.scss b/app/frontend/stylesheets/application.scss index c8dc7f8a..6307d210 100644 --- a/app/frontend/stylesheets/application.scss +++ b/app/frontend/stylesheets/application.scss @@ -72,6 +72,7 @@ h3 { font-size: 1.5rem; } @import "./snippets/identities.scss"; @import "./snippets/welcome.scss"; @import "./snippets/email_changes.scss"; +@import "./snippets/developer_apps.scss"; input[type="text"], input[type="email"], diff --git a/app/frontend/stylesheets/snippets/developer_apps.scss b/app/frontend/stylesheets/snippets/developer_apps.scss new file mode 100644 index 00000000..8fad87be --- /dev/null +++ b/app/frontend/stylesheets/snippets/developer_apps.scss @@ -0,0 +1,310 @@ +// Developer Apps UI — IdP management panel styles + +// Form field container +.field-group { + margin-bottom: 1.25rem; + label:first-child { + display: block; + font-weight: 600; + font-size: 0.9rem; + margin-bottom: 0.4rem; + } +} + +// Checkbox + label + optional description row +.checkbox-label { + display: flex; + align-items: flex-start; + gap: 0.6rem; + padding: 0.375rem 0; + cursor: pointer; + input[type="checkbox"] { margin-top: 0.2rem; flex-shrink: 0; } + small { display: block; margin-top: 0.1rem; } +} + +// Secondary button style for tags — replicates Pico's .secondary for buttons +a.button-secondary, +.button-secondary { + background: #fff linear-gradient(180deg, rgba(255,255,255,0) 0%, rgba(0,0,0,0.04) 100%) !important; + background-color: #fff !important; + color: #555 !important; + border: 1px solid rgba(0, 0, 0, 0.1) !important; + box-shadow: + 0 1px 3px rgba(0, 0, 0, 0.08), + inset 0 1px 0 rgba(255, 255, 255, 0.8), + inset 0 -1px 1px rgba(0, 0, 0, 0.04) !important; + display: inline-flex; + align-items: center; + justify-content: center; + text-decoration: none; + border-radius: var(--pico-border-radius); + padding: var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal); + font-size: 1rem; + font-weight: var(--pico-font-weight); + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + + @include dark-mode { + background: #2c2c2c linear-gradient(180deg, rgba(255,255,255,0) 0%, rgba(0,0,0,0.1) 100%) !important; + color: #d0d0d0 !important; + border-color: rgba(255, 255, 255, 0.1) !important; + box-shadow: + 0 2px 6px rgba(0, 0, 0, 0.4), + inset 0 1px 0 rgba(255, 255, 255, 0.08), + inset 0 -1px 1px rgba(0, 0, 0, 0.3) !important; + } + + &:hover { + transform: translateY(-1px); + background: #fcfcfc linear-gradient(180deg, rgba(255,255,255,0) 0%, rgba(0,0,0,0.02) 100%) !important; + border-color: rgba(0, 0, 0, 0.15) !important; + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.12), + inset 0 1px 0 rgba(255, 255, 255, 1), + inset 0 -1px 1px rgba(0, 0, 0, 0.05) !important; + + @include dark-mode { + background: #333 linear-gradient(180deg, rgba(255,255,255,0) 0%, rgba(0,0,0,0.15) 100%) !important; + border-color: rgba(255, 255, 255, 0.15) !important; + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.5), + inset 0 1px 0 rgba(255, 255, 255, 0.1), + inset 0 -1px 1px rgba(0, 0, 0, 0.3) !important; + } + } + + &:active { + transform: translateY(0); + box-shadow: + inset 0 3px 6px rgba(0, 0, 0, 0.15), + inset 0 1px 3px rgba(0, 0, 0, 0.12) !important; + + @include dark-mode { + box-shadow: + inset 0 3px 6px rgba(0, 0, 0, 0.5), + inset 0 1px 3px rgba(0, 0, 0, 0.4) !important; + } + } +} + +// Submit row +.form-actions { display: flex; gap: 0.75rem; margin-top: 1.5rem; flex-wrap: wrap; } + +// Two-panel layout for show page +.dev-panel { + display: grid; + grid-template-columns: 260px 1fr; + gap: $space-6; + align-items: start; + margin-top: $space-5; + @include down($bp-md) { grid-template-columns: 1fr; } +} +.dev-panel-sidebar { display: flex; flex-direction: column; gap: $space-4; } +.dev-panel-main { display: flex; flex-direction: column; gap: $space-4; } + +// Monogram icon for app identity +.app-icon { + width: 48px; height: 48px; + border-radius: $radius-md; + background: linear-gradient(135deg, var(--pico-primary-background, var(--pico-primary)) 0%, var(--pico-primary) 100%); + display: flex; align-items: center; justify-content: center; + font-size: 1.25rem; font-weight: 700; color: white; + flex-shrink: 0; + box-shadow: 0 2px 8px rgba(0,0,0,0.15); + letter-spacing: -0.02em; + &.large { width: 72px; height: 72px; font-size: 1.875rem; border-radius: $radius-lg; margin: 0 auto $space-3; } +} + +// Sidebar identity card internals +.app-identity-name { + font-size: 1.25rem; + margin: 0 0 $space-2; +} + +.app-badge-row { + display: flex; + gap: $space-2; + justify-content: center; + flex-wrap: wrap; + margin-bottom: $space-3; +} + +.dev-panel-sidebar .button-secondary { + text-align: center; + display: block; +} + +// Redirect URI rows in show page +.redirect-uri-list { margin-top: $space-4; } + +.redirect-uri-row { + display: grid; + grid-template-columns: 1fr auto; + gap: $space-2; + align-items: center; + margin-bottom: $space-2; + input[readonly] { + font-family: $font-mono; + font-size: 0.9rem; + } +} + +// Collaborator list in show page +.collaborator-list { margin-top: $space-4; } + +.collaborator-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: $space-2 0; + border-bottom: 1px solid var(--pico-card-border-color); + .collaborator-email { + color: var(--pico-muted-color); + font-size: 0.9rem; + } +} + +.add-collaborator { + margin-top: $space-4; + .add-collaborator-label { + font-size: 0.8rem; + font-weight: 600; + color: var(--pico-muted-color); + text-transform: uppercase; + letter-spacing: 0.04em; + margin: 0 0 $space-2; + } + form { + display: flex; + gap: $space-2; + input[type="email"] { flex: 1; } + } +} + +// Scope description text inside checkbox labels +.checkbox-label small { color: var(--pico-muted-color); } + +// YSWS defaults quick-fill button spacing +.ysws-defaults-btn { margin-bottom: $space-3; } + +// Label/value pair in show sidebar +.app-meta-row { + display: flex; flex-direction: column; gap: 0.2rem; + padding: 0.5rem 0; + border-bottom: 1px solid var(--pico-card-border-color); + &:last-child { border-bottom: none; } + .meta-label { font-size: 0.8rem; font-weight: 600; color: var(--pico-muted-color); text-transform: uppercase; letter-spacing: 0.04em; } + .meta-value { font-size: 0.9375rem; } +} + +// Credential rows — divider-separated, no nested boxes +.credential-block { + padding: $space-3 0; + border-bottom: 1px solid var(--pico-card-border-color); + &:first-of-type { margin-top: $space-3; } + &:last-of-type { border-bottom: none; padding-bottom: 0; } + label { font-size: 0.75rem; font-weight: 600; color: var(--pico-muted-color); text-transform: uppercase; letter-spacing: 0.05em; display: block; margin-bottom: 0.3rem; } + input[readonly] { + font-family: $font-mono; font-size: 0.9rem; + background: transparent; border: none; padding: 0; + cursor: pointer; box-shadow: none; margin: 0; width: 100%; + color: var(--pico-color); + } +} + +.cred-reveal-row { + display: flex; + align-items: center; + gap: $space-2; + .pointer { flex: 1; min-width: 0; } + input[readonly] { width: 100%; } + button { flex-shrink: 0; } +} + +// Danger zone card variant +.danger-card { + border-color: var(--error-border) !important; + h4 { color: var(--error-fg); font-size: 1rem; margin: 0 0 $space-3 0; } +} + +// Thin search strip (replaces heavy section-card for admin search) +.search-bar { + display: flex; + align-items: center; + gap: $space-3; + flex-wrap: wrap; + padding: $space-3 $space-4; + background: var(--surface-2); + border: 1px solid var(--pico-card-border-color); + border-radius: $radius-lg; + margin-bottom: $space-4; + form { display: contents; } + input[type="search"], input[type="text"] { + flex: 1; min-width: 200px; + margin: 0; padding: 0.5rem 0.75rem; font-size: 0.9rem; + } + .search-results-count { + font-size: 0.875rem; + color: var(--pico-muted-color); + margin-left: auto; + } + button, button[type="submit"] { flex-shrink: 0; width: auto; margin: 0; } +} + +// Locked scope checkboxes (user can't touch these) +.checkbox-label.locked { + opacity: 0.55; + cursor: not-allowed; + input[type="checkbox"] { cursor: not-allowed; } +} + +.locked-scopes-label { + font-size: 0.8rem; + font-weight: 600; + color: var(--pico-muted-color); + text-transform: uppercase; + letter-spacing: 0.04em; + margin: $space-3 0 $space-2; +} + +// Warning banner when trust level downgrade strips scopes +.scope-strip-warning { + display: flex; + align-items: center; + gap: $space-2; + flex-wrap: wrap; + padding: $space-2 $space-3; + background: var(--warning-bg); + border: 1px solid var(--warning-border); + border-radius: $radius-md; + font-size: 0.9rem; + margin-bottom: $space-3; + color: var(--warning-fg-strong); + button { margin-left: auto; flex-shrink: 0; } +} + +// Compact app list for index +.app-list { + display: flex; + flex-direction: column; + gap: $space-3; +} + +.app-list-item { + display: flex; + align-items: center; + gap: $space-4; + padding: $space-4; + + .app-list-item-identity { + display: flex; align-items: center; gap: $space-3; flex: 1; min-width: 0; + h3 { margin: 0; font-size: 1rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + small { color: var(--pico-muted-color); font-size: 0.8125rem; } + } + + .app-list-item-meta { + display: flex; align-items: center; gap: $space-3; flex-shrink: 0; + .client-id { font-family: $font-mono; font-size: 0.8125rem; color: var(--pico-muted-color); } + @include down($bp-md) { .client-id { display: none; } } + } +} diff --git a/app/lib/shortcodes.rb b/app/lib/shortcodes.rb index 68d1e3e1..d9489c86 100644 --- a/app/lib/shortcodes.rb +++ b/app/lib/shortcodes.rb @@ -31,9 +31,9 @@ def all(user = nil) # Common shortcuts << Shortcode.new(code: "LOGS", label: "Audit logs", controller: "backend/audit_logs", action: "index", icon: "⭢", role: :general, path_override: nil) - # Program manager + # Program manager / developer if user&.program_manager? || user&.super_admin? - shortcuts << Shortcode.new(code: "APPS", label: "OAuth2 apps", controller: "backend/programs", action: "index", icon: "⭢", role: :program_manager, path_override: nil) + shortcuts << Shortcode.new(code: "APPS", label: "OAuth2 apps", controller: "developer_apps", action: "index", icon: "⭢", role: :program_manager, path_override: "/developer/apps") end # Super admin (less frequent) diff --git a/app/models/identity.rb b/app/models/identity.rb index 6baa254e..c12e20b5 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -83,6 +83,9 @@ def active_for_backend? has_many :owned_developer_apps, class_name: "Program", foreign_key: :owner_identity_id, dependent: :nullify + has_many :program_collaborators, dependent: :destroy + has_many :collaborated_programs, through: :program_collaborators, source: :program + validates :first_name, :last_name, :country, :primary_email, :birthday, presence: true validates :primary_email, uniqueness: { conditions: -> { where(deleted_at: nil) } } validate :validate_primary_email, if: -> { new_record? || primary_email_changed? } @@ -172,6 +175,11 @@ def self.link_slack_account(code, redirect_uri, current_identity) { success: true, slack_id: slack_id } end + def accessible_developer_apps + Program.where(id: owned_developer_apps.select(:id)) + .or(Program.where(id: collaborated_programs.select(:id))) + end + def slack_linked? = slack_id.present? def onboarding_scenario_instance diff --git a/app/models/oauth_scope.rb b/app/models/oauth_scope.rb index 7723acad..e6cfdd13 100644 --- a/app/models/oauth_scope.rb +++ b/app/models/oauth_scope.rb @@ -126,6 +126,9 @@ def initialize(name:, description:, consent_fields: [], includes: [], icon: nil) BY_NAME = ALL.index_by(&:name).freeze COMMUNITY_ALLOWED = %w[openid profile email name slack_id verification_status].freeze + HQ_OFFICIAL_SCOPES = (COMMUNITY_ALLOWED + %w[basic_info birthdate phone address]).freeze + SUPER_ADMIN_SCOPES = (HQ_OFFICIAL_SCOPES + %w[legal_name]).freeze + # set_slack_id intentionally omitted from all tiers — valid but not assignable via UI def self.find(name) BY_NAME[name.to_s] diff --git a/app/models/program.rb b/app/models/program.rb index 3cbd232b..f504bb83 100644 --- a/app/models/program.rb +++ b/app/models/program.rb @@ -43,6 +43,9 @@ class Program < ApplicationRecord has_many :organizer_positions, class_name: "Backend::OrganizerPosition", foreign_key: :program_id, dependent: :destroy has_many :organizers, through: :organizer_positions, source: :backend_user, class_name: "Backend::User" + has_many :program_collaborators, dependent: :destroy + has_many :collaborator_identities, through: :program_collaborators, source: :identity + belongs_to :owner_identity, class_name: "Identity", optional: true validates :name, presence: true @@ -95,6 +98,16 @@ def onboarding_scenario_instance(identity = nil) onboarding_scenario_class&.new(identity) end + def collaborator?(identity) + return false unless identity + program_collaborators.exists?(identity: identity) + end + + def accessible_by?(identity) + return false unless identity + owner_identity_id == identity.id || collaborator?(identity) + end + def rotate_credentials! self.secret = SecureRandom.hex(32) self.program_key = "prgmk." + SecureRandom.hex(32) diff --git a/app/models/program_collaborator.rb b/app/models/program_collaborator.rb new file mode 100644 index 00000000..6490de55 --- /dev/null +++ b/app/models/program_collaborator.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class ProgramCollaborator < ApplicationRecord + belongs_to :program + belongs_to :identity + + validates :identity_id, uniqueness: { scope: :program_id } +end diff --git a/app/policies/program_policy.rb b/app/policies/program_policy.rb index adb2eee6..f74b5931 100644 --- a/app/policies/program_policy.rb +++ b/app/policies/program_policy.rb @@ -1,45 +1,121 @@ +# frozen_string_literal: true + +# `user` here is always an Identity (via IdentityAuthorizable). class ProgramPolicy < ApplicationPolicy - def index? = user_is_program_manager? || user_has_assigned_programs? + def index? + user.developer_mode? || admin? + end + + def show? + owner? || collaborator? || admin? + end + + def create? + user.developer_mode? || admin? + end + + def new? + create? + end + + def update? + owner? || collaborator? || admin? + end + + def edit? + update? + end + + def destroy? + owner? || admin? + end + + def update_trust_level? + user.can_hq_officialize? || admin? + end + + def update_scopes? + owner? || collaborator? || admin? + end - def show? = user_is_program_manager? || user_has_access_to_program? + def update_all_scopes? + admin? + end - def create? = user_is_program_manager? + # Returns the list of scope names this user is permitted to add or remove. + # Scopes outside this list that already exist on the app are "locked" — + # preserved on save but not editable by this user. + def allowed_scopes + if super_admin? + OAuthScope::SUPER_ADMIN_SCOPES + elsif user.can_hq_officialize? || admin? + OAuthScope::HQ_OFFICIAL_SCOPES + else + OAuthScope::COMMUNITY_ALLOWED + end + end - def update? = user_is_program_manager? || user_has_access_to_program? + def update_onboarding_scenario? + super_admin? + end - def destroy? = user_is_program_manager? + def update_active? + admin? + end - def update_basic_fields? = user_has_access_to_program? + def view_secret? + owner? || admin? + end - def update_scopes? = user_is_program_manager? + def view_api_key? + admin? + end - def update_onboarding_scenario? = user&.super_admin? + def rotate_credentials? + owner? || admin? + end - def rotate_credentials? = user_is_program_manager? + def revoke_all_authorizations? + owner? || admin? + end + + def manage_collaborators? + owner? + end - class Scope < Scope + class Scope < ApplicationPolicy::Scope def resolve - if user.program_manager? || user.super_admin? - # Program managers and super admins can see all programs + if admin? scope.all else - # Regular users can only see programs they are assigned to - scope.joins(:organizer_positions).where(backend_organizer_positions: { backend_user_id: user.id }) + user.accessible_developer_apps end end + + private + + def admin? + backend_user = user.backend_user + backend_user&.program_manager? || backend_user&.super_admin? + end end private - def user_is_program_manager? - user.present? && (user.program_manager? || user.super_admin?) + def owner? + record.is_a?(Class) ? false : record.owner_identity_id == user.id + end + + def collaborator? + record.is_a?(Class) ? false : record.collaborator?(user) end - def user_has_assigned_programs? - user.present? && user.organized_programs.any? + def admin? + backend_user = user.backend_user + backend_user&.program_manager? || backend_user&.super_admin? end - def user_has_access_to_program? - user_is_program_manager? || (user.present? && user.organized_programs.include?(record)) + def super_admin? + user.backend_user&.super_admin? end end diff --git a/app/views/backend/identities/edit.html.erb b/app/views/backend/identities/edit.html.erb index 9f4cc6bb..59d104f9 100644 --- a/app/views/backend/identities/edit.html.erb +++ b/app/views/backend/identities/edit.html.erb @@ -70,6 +70,11 @@ <%= f.check_box :permabanned %> permanently ban (makes ineligible) +
+ <%= f.label :can_hq_officialize, "can officialize apps" %> + <%= f.check_box :can_hq_officialize %> + allow this identity to promote apps to hq_official +
<% end %> diff --git a/app/views/backend/programs/_program.html.erb b/app/views/backend/programs/_program.html.erb deleted file mode 100644 index e3220aad..00000000 --- a/app/views/backend/programs/_program.html.erb +++ /dev/null @@ -1,6 +0,0 @@ - - <%= inline_icon("briefcase", size: 16) %> - <%= link_to backend_program_path(program), class: "identity-link", target: "_blank" do %> - <%= program.name %> - <% end %> - diff --git a/app/views/backend/programs/edit.html.erb b/app/views/backend/programs/edit.html.erb deleted file mode 100644 index 11ba5f7f..00000000 --- a/app/views/backend/programs/edit.html.erb +++ /dev/null @@ -1,9 +0,0 @@ -
- <%= render Components::Backend::Card.new(title: "edit: #{@program.name}") do %> - <%= render Components::Backend::Item.new(icon: "⭠", href: backend_program_path(@program)) do %> - cancel - <% end %> -
- <%= render Backend::Programs::Form.new @program %> - <% end %> -
diff --git a/app/views/backend/programs/index.html.erb b/app/views/backend/programs/index.html.erb deleted file mode 100644 index ade33cdb..00000000 --- a/app/views/backend/programs/index.html.erb +++ /dev/null @@ -1,45 +0,0 @@ -
- <%= render Components::Backend::Card.new(title: "oauth programs") do %> -
- - - - - - - - - - - - <% @programs.each do |program| %> - - - - - - - - <% end %> - -
nameownerscopesusersactive
- <%= link_to backend_program_path(program) do %> - <%= program.name %> - <% end %> - <% if program.description.present? %> -
<%= truncate(program.description, length: 40) %> - <% end %> -
- <% if program.owner_identity.present? %> - <%= render Components::UserMention.new(program.owner_identity) %> - <% else %> - HQ - <% end %> - <%= program.scopes.presence || "—" %><%= program.identities.distinct.count %><%= render_checkbox(program.active?) %>
-
-
- <%= render Components::Backend::Item.new(icon: "+", href: new_backend_program_path) do %> - new program - <% end %> - <% end %> -
diff --git a/app/views/backend/programs/new.html.erb b/app/views/backend/programs/new.html.erb deleted file mode 100644 index 293fbd24..00000000 --- a/app/views/backend/programs/new.html.erb +++ /dev/null @@ -1,9 +0,0 @@ -
- <%= render Components::Backend::Card.new(title: "new program") do %> - <%= render Components::Backend::Item.new(icon: "⭠", href: backend_programs_path) do %> - cancel - <% end %> -
- <%= render Backend::Programs::Form.new @program %> - <% end %> -
diff --git a/app/views/backend/programs/show.html.erb b/app/views/backend/programs/show.html.erb deleted file mode 100644 index 9e3e927c..00000000 --- a/app/views/backend/programs/show.html.erb +++ /dev/null @@ -1,95 +0,0 @@ -
- <%= render Components::Backend::Card.new(title: "program: #{@program.name}") do %> - <%= render Components::Backend::Item.new(icon: "⭠", href: backend_programs_path) do %> - back to programs - <% end %> -
-
-

details

-
- <% if @program.description.present? %> -

<%= @program.description %>

- <% end %> -
- owner - - <% if @program.owner_identity.present? %> - <%= render Components::UserMention.new(@program.owner_identity) %> - <% else %> - HQ - <% end %> - -
-
- status - <%= @program.active? ? "active" : "inactive" %> -
-
- trust - <%= @program.trust_level.to_s.titleize %> -
-
- scopes - <%= @program.scopes.presence || "none" %> -
-
- users - <%= @identities_count %> -
- <% if @program.onboarding_scenario.present? %> -
- onboarding - <%= @program.onboarding_scenario.titleize %> -
- <% end %> -
-
-
-
-

credentials

-
-
- - -
-
- - -
-
- - -
- <% if policy(@program).rotate_credentials? %> -
- <%= link_to "rotate secret & api key", rotate_credentials_backend_program_path(@program), method: :post, data: { confirm: "are you sure? this will invalidate the current secret and api key. any integrations using them will break." }, class: "btn btn-danger" %> -
- <% end %> -
-
- <% if @program.redirect_uri.present? %> -
-

redirect uris

-
- <% @program.redirect_uri.split.each do |uri| %> -
- <%= uri %> - <%= link_to "auth →", oauth_authorization_path(client_id: @program.uid, redirect_uri: uri, response_type: 'code', scope: @program.scopes), target: '_blank' %> -
- <% end %> -
-
- <% end %> -
- <%= render Components::Backend::Item.new(icon: "✎", href: edit_backend_program_path(@program)) do %> - edit program - <% end %> - <%= render Components::Backend::Item.new(icon: "⭢", href: oauth_application_path(@program), target: "_blank") do %> - oauth app - <% end %> - <%= link_to backend_program_path(@program), method: :delete, data: { confirm: "delete this program and all associated data?" }, class: "item" do %> -
- delete program - <% end %> - <% end %> -
diff --git a/app/views/backend/static_pages/index.html.erb b/app/views/backend/static_pages/index.html.erb index d4e74f15..0d54ae8c 100644 --- a/app/views/backend/static_pages/index.html.erb +++ b/app/views/backend/static_pages/index.html.erb @@ -32,7 +32,7 @@ <% if current_user&.program_manager? || current_user&.super_admin? %> Program manager:
- <%= render Components::Backend::Item.new(icon: "⭢", href: backend_programs_path) do %> + <%= render Components::Backend::Item.new(icon: "⭢", href: developer_apps_path) do %> Manage OAuth2 apps

  [ APPS ]

<% end %> diff --git a/app/views/developer_apps/edit.html.erb b/app/views/developer_apps/edit.html.erb index 6cbaa5d4..bc383421 100644 --- a/app/views/developer_apps/edit.html.erb +++ b/app/views/developer_apps/edit.html.erb @@ -3,6 +3,15 @@ <%= link_to t(".back_to_app"), developer_app_path(@app) %>
+<%# Compute once for Alpine init and server-side locked-scope rendering %> +<% editor_data = { + trustLevel: @app.trust_level, + selectedScopes: @app.scopes_array, + allowedScopes: policy(@app).allowed_scopes, + communityScopes: Program::COMMUNITY_ALLOWED_SCOPES, + allScopes: Program::AVAILABLE_SCOPES + } %> + <%= form_with model: @app, url: developer_app_path(@app), method: :patch, local: true do |f| %> <% if @app.errors.any? %> <%= render Components::Banner.new(kind: :danger) do %> @@ -15,33 +24,119 @@ <% end %> <% end %> -
- <%= f.label :name, t(".app_name") %> - <%= f.text_field :name, required: true %> -
+
+ <%# === Card 1: App Details === %> +
+

<%= t(".app_details_heading", default: "App Details") %>

+
+ <%= f.label :name, t(".app_name") %> + <%= f.text_field :name, required: true %> +
+
+ <%= f.label :redirect_uri, t(".redirect_uri") %> + <%= f.text_area :redirect_uri, rows: 3, required: true %> + <%= t ".redirect_uri_hint" %> +
+
-
- <%= f.label :redirect_uri, t(".redirect_uri") %> - <%= f.text_area :redirect_uri, rows: 3, required: true %> - <%= t ".redirect_uri_hint" %> -
+ <%# === Cards 2+3 wrapped in Alpine === %> +
+ + <%# === Card 2: Trust Level (if user can edit it) === %> + <% if policy(@app).update_trust_level? %> +
+

<%= t(".trust_level_heading", default: "Trust Level") %>

+
+ <%= f.label :trust_level, t(".trust_level") %> + <%= f.select :trust_level, + Program.trust_levels.keys.map { |k| [k.titleize, k] }, + {}, { "x-model": "trustLevel", "@change": "onTrustLevelChange()" } %> + <%= t(".trust_level_hint", default: "Controls which scopes the app may request and how the consent screen appears to users.") %> +
+
+ <% end %> + + <%# === Card 3: OAuth Scopes === %> +
+

<%= t(".oauth_scopes_heading", default: "OAuth Scopes") %>

+ + <%# Warning when trust level downgrade stripped scopes %> +
+ <%= t(".scopes_removed", default: "Scopes removed:") %> + + — <%= t(".scopes_not_valid_for_trust_level", default: "not valid for this trust level.") %> + +
+ + <%# Blank input ensures empty array when nothing checked %> + -
- <%= f.label :scopes_array, t(".scopes") %> - - <% Program::COMMUNITY_ALLOWED_SCOPES.each do |scope| %> - + <%# Editable scope checkboxes (Alpine-rendered) %> + + + <%# Locked scopes — hidden inputs to preserve, shown as disabled rows %> + + + <%# Community-only note for users who can't change trust level %> + <% unless policy(@app).update_trust_level? %> + <%= render Components::Banner.new(kind: :info) do %> + <%= t("developer_apps.new.trust_level_note_html").html_safe %> + <% end %> + <% end %> +
+
+ + <%# === Card 4: Admin Settings (if applicable) === %> + <% if policy(@app).update_onboarding_scenario? || policy(@app).update_active? %> +
+

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

+ <% if policy(@app).update_onboarding_scenario? %> +
+ <%= f.label :onboarding_scenario, t(".onboarding_scenario") %> + <%= f.select :onboarding_scenario, OnboardingScenarios::Base.available_slugs.map { |s| [s.titleize, s] }, { include_blank: t(".onboarding_default") }, class: "input-field" %> + <%= t ".onboarding_scenario_hint" %> +
+ <% end %> + <% if policy(@app).update_active? %> +
+ +
+ <% end %> +
<% end %> - <%= t ".scopes_hint", scopes: Program::COMMUNITY_ALLOWED_SCOPES.join(", ") %>
-
+
<%= f.submit t(".update"), class: "button" %> <%= link_to t(".cancel"), developer_app_path(@app), class: "button-secondary" %>
diff --git a/app/views/developer_apps/index.html.erb b/app/views/developer_apps/index.html.erb index f220ed27..3f0a0607 100644 --- a/app/views/developer_apps/index.html.erb +++ b/app/views/developer_apps/index.html.erb @@ -1,46 +1,51 @@ + <%= paginate @apps %> + <% else %> +
+
<%= inline_icon("code", size: 48) %>
+

<%= t ".blank_slate.title" %>

+

<%= t ".blank_slate.cta" %>

+ <%= link_to t(".blank_slate.create"), new_developer_app_path, role: "button" %> +
<% end %> -<% else %> -
-
<%= inline_icon("code", size: 48) %>
-

<%= t ".blank_slate.title" %>

-

<%= t ".blank_slate.cta" %>

- <%= link_to t(".blank_slate.create"), new_developer_app_path, role: "button" %> -
-<% end %> +
diff --git a/app/views/developer_apps/new.html.erb b/app/views/developer_apps/new.html.erb index dd2738e5..186c7839 100644 --- a/app/views/developer_apps/new.html.erb +++ b/app/views/developer_apps/new.html.erb @@ -3,6 +3,19 @@ <%= link_to t(".back_to_apps"), developer_apps_path %> +<%# Compute once for Alpine init. + @app.trust_level is community_untrusted from the controller (both the new + action default and the create action's server-side enforcement). On + validation-error re-render it preserves whatever the user submitted. %> +<% editor_data = { + trustLevel: @app.trust_level, + selectedScopes: @app.scopes_array, + allowedScopes: policy(@app).allowed_scopes, + communityScopes: Program::COMMUNITY_ALLOWED_SCOPES, + allScopes: Program::AVAILABLE_SCOPES, + yswsDefaults: policy(@app).update_trust_level? + } %> + <%= form_with model: @app, url: developer_apps_path, method: :post, local: true do |f| %> <% if @app.errors.any? %> <%= render Components::Banner.new(kind: :danger) do %> @@ -15,38 +28,84 @@ <% end %> <% end %> -
- <%= f.label :name, t(".app_name") %> - <%= f.text_field :name, placeholder: t(".app_name_placeholder"), required: true %> -
+
+ <%# === Card 1: App Details === %> +
+

<%= t(".app_details_heading", default: "App Details") %>

+
+ <%= f.label :name, t(".app_name") %> + <%= f.text_field :name, placeholder: t(".app_name_placeholder"), required: true %> +
+
+ <%= f.label :redirect_uri, t(".redirect_uri") %> + <%= f.text_area :redirect_uri, placeholder: t(".redirect_uri_placeholder"), rows: 3, required: true %> + <%= t ".redirect_uri_hint" %> +
+
-
- <%= f.label :redirect_uri, t(".redirect_uri") %> - <%= f.text_area :redirect_uri, placeholder: t(".redirect_uri_placeholder"), rows: 3, required: true %> - <%= t ".redirect_uri_hint" %> -
+ <%# === Cards 2+3 wrapped in Alpine === %> +
-
- <%= f.label :scopes_array, t(".scopes") %> - - <% Program::COMMUNITY_ALLOWED_SCOPES.each do |scope| %> - - <% end %> - <%= t ".scopes_hint", scopes: Program::COMMUNITY_ALLOWED_SCOPES.join(", ") %> + <%# === Card 2: Trust Level (if user can set it) === %> + <% if policy(@app).update_trust_level? %> +
+

<%= t(".trust_level_heading", default: "Trust Level") %>

+
+ <%= f.label :trust_level, t(".trust_level") %> + <%= f.select :trust_level, + Program.trust_levels.keys.map { |k| [k.titleize, k] }, + {}, + { "x-model": "trustLevel", "@change": "onTrustLevelChange()" } %> + <%= t(".trust_level_hint", default: "Controls which scopes the app may request and how the consent screen appears to users.") %> +
+
+ <% end %> + + <%# === Card 3: OAuth Scopes === %> +
+

<%= t(".oauth_scopes_heading", default: "OAuth Scopes") %>

+ + <%# Warning when trust level downgrade stripped scopes %> +
+ <%= t("developer_apps.edit.scopes_removed", default: "Scopes removed:") %> + + — <%= t("developer_apps.edit.scopes_not_valid_for_trust_level", default: "not valid for this trust level.") %> + +
+ + <%# Quick-fill for YSWS programs (HQ officializers only) %> + + + <%# Blank input ensures empty array when nothing checked %> + + + <%# Editable scope checkboxes (Alpine-rendered) %> + + + <%# Community-only note for users who can't change trust level %> + <% unless policy(@app).update_trust_level? %> + <%= render Components::Banner.new(kind: :info) do %> + <%= t(".trust_level_note_html").html_safe %> + <% end %> + <% end %> +
+
-
-
- <%= render Components::Banner.new(kind: :info) do %> - <%= t(".trust_level_note_html").html_safe %> - <% end %> -
+
<%= f.submit t(".create"), class: "button" %> <%= link_to t(".cancel"), developer_apps_path, class: "button-secondary" %>
diff --git a/app/views/developer_apps/show.html.erb b/app/views/developer_apps/show.html.erb index 0960e864..173c7e44 100644 --- a/app/views/developer_apps/show.html.erb +++ b/app/views/developer_apps/show.html.erb @@ -2,73 +2,156 @@

<%= @app.name %>

<%= link_to t(".back_to_apps"), developer_apps_path %>
-<%= render Components::Banner.new(kind: :warning) do %> - <%= t ".security_warning_title" %>
- <%= t ".security_warning_message" %> -<% end %> -
-

<%= t ".oauth_credentials" %>

-
-
- - <%= copy_to_clipboard @app.uid, label: t(".click_to_copy_client_id") do %> - <%= text_field_tag :client_id, @app.uid, readonly: true, style: "font-family: monospace; font-size: 0.9rem; cursor: pointer;", autocomplete: "off" %> - <% end %> -
+
+
+ + <%# Quick actions %> + <%= link_to t(".edit_app"), edit_developer_app_path(@app), class: "button-secondary" %> + + <%# Danger zone %> + <% if policy(@app).rotate_credentials? || policy(@app).destroy? %> +
+

<%= t(".danger_zone") %>

+ <% if policy(@app).rotate_credentials? %> + <%= button_to t(".rotate_credentials"), rotate_credentials_developer_app_path(@app), method: :post, class: "danger small-btn", + form: { onsubmit: "return confirm('#{j t('.rotate_confirm')}')" } %> + <% end %> + <% if policy(@app).revoke_all_authorizations? %> + <%= button_to t(".revoke_all_authorizations"), revoke_all_authorizations_developer_app_path(@app), method: :post, class: "danger small-btn", + form: { onsubmit: "return confirm('#{j t('.revoke_all_authorizations_confirm')}')" } %> + <% end %> + <% if policy(@app).destroy? %> + <%= button_to t(".delete_app"), developer_app_path(@app), method: :delete, class: "danger small-btn", + form: { onsubmit: "return confirm('#{j t('.delete_confirm')}')" } %> + <% end %> +
+ <% end %> + + +
+ <%# Security warning %> + <%= render Components::Banner.new(kind: :warning) do %> + <%= t ".security_warning_title" %>
+ <%= t ".security_warning_message" %> + <% end %> -
-

<%= t ".configuration" %>

+ <%# OAuth Credentials %> +
+

<%= t ".oauth_credentials" %>

+
+ + <%= copy_to_clipboard @app.uid, label: t(".click_to_copy_client_id") do %> + <%= text_field_tag :client_id, @app.uid, readonly: true, autocomplete: "off" %> + <% end %> +
+ <% if policy(@app).view_secret? %> +
+ +
+ <%= copy_to_clipboard @app.secret, label: t(".click_to_copy_client_secret") do %> + + <% end %> + +
+
+ <% end %> + <% if policy(@app).view_api_key? %> +
+ +
+ <%= copy_to_clipboard @app.program_key, label: t(".click_to_copy_api_key") do %> + + <% end %> + +
+
+ <% end %> +
-
-
- -
- <% if @app.scopes_array.any? %> - <%= text_field_tag :scopes, @app.scopes_array.join(", "), readonly: true, autocomplete: "off" %> - <% else %> - <%= t ".no_scopes" %> + <%# Redirect URIs %> +
+

<%= t ".redirect_uris" %>

+
+ <% @app.redirect_uri.split.each do |uri| %> +
+ <%= text_field_tag "redirect_uri_#{uri.hash}", uri, readonly: true, autocomplete: "off" %> + <%= link_to oauth_authorization_path(client_id: @app.uid, redirect_uri: uri, response_type: 'code', scope: @app.scopes), + class: "button-secondary small-btn", target: "_blank" do %> + <%= t(".test_auth") %> <%= inline_icon("external", size: 14) %> + <% end %> +
<% end %> + <%= t ".auth_link_hint" %>
-
+
-
- - <%= text_field_tag :trust_level, @app.trust_level.to_s.titleize, readonly: true, autocomplete: "off" %> -
-
- + <%# Collaborators (owner only) %> + <% if policy(@app).manage_collaborators? %> +
+

<%= t(".collaborators") %>

+
+ <% if @collaborators.any? %> + <% @collaborators.each do |collab| %> +
+
+ <%= collab.identity.full_name %> + <%= collab.identity.primary_email %> +
+ <%= button_to t(".remove_collaborator"), developer_app_collaborator_path(@app, collab), method: :delete, class: "danger small-btn", + form: { onsubmit: "return confirm('#{j t('.remove_collaborator_confirm')}')" } %> +
+ <% end %> + <% else %> +

<%= t(".no_collaborators") %>

+ <% end %> +
+
+

<%= t(".add_collaborator") %>

+ <%= form_with url: developer_app_collaborators_path(@app), method: :post, local: true do |f| %> + <%= email_field_tag :email, nil, placeholder: t(".collaborator_email_placeholder"), required: true %> + + <% end %> +
+
+ <% end %> -
- <%= link_to t(".edit_app"), edit_developer_app_path(@app), class: "button" %> - <%= button_to t(".delete_app"), developer_app_path(@app), method: :delete, class: "danger small-btn", - form: { "hx-confirm": t(".delete_confirm") } %> + <%# Trust level is now managed through the edit form %> +
diff --git a/app/views/forms/backend/programs/form.rb b/app/views/forms/backend/programs/form.rb deleted file mode 100644 index f930b24d..00000000 --- a/app/views/forms/backend/programs/form.rb +++ /dev/null @@ -1,78 +0,0 @@ -class Backend::Programs::Form < ApplicationForm - def view_template(&) - div do - labeled field(:name).input, "Program Name: " - end - div do - label(class: "field-label") { "Redirect URIs (one per line):" } - textarea( - name: "oauth_application[redirect_uri]", - placeholder: "https://example.com/callback", - class: "input-field", - rows: 3, - style: "width: 100%;", - ) { model.redirect_uri } - end - program_manager_tool do - div style: "margin: 1rem 0;" do - label(class: "field-label") { "Trust Level:" } - select( - name: "program[trust_level]", - class: "input-field", - style: "width: 100%; margin-bottom: 1rem;" - ) do - Program.trust_levels.each do |key, value| - option( - value: key, - selected: model.trust_level == key - ) { key.titleize } - end - end - end - - super_admin_tool do - div style: "margin: 1rem 0;" do - label(class: "field-label") { "Onboarding Scenario:" } - select( - name: "program[onboarding_scenario]", - class: "input-field", - style: "width: 100%; margin-bottom: 1rem;" - ) do - option(value: "", selected: model.onboarding_scenario.blank?) { "(default)" } - OnboardingScenarios::Base.available_slugs.each do |slug| - option( - value: slug, - selected: model.onboarding_scenario == slug - ) { slug.titleize } - end - end - small(style: "display: block; color: var(--muted-color); margin-top: -0.5rem;") do - plain "When users sign up through this OAuth app, they'll use this onboarding flow" - end - end - end - - div style: "margin: 1rem 0;" do - label(class: "field-label") { "OAuth Scopes:" } - # Hidden field to ensure empty scopes array is submitted when no checkboxes are checked - input type: "hidden", name: "program[scopes_array][]", value: "" - Program::AVAILABLE_SCOPES.each do |scope| - div class: "checkbox-row" do - scope_checked = model.persisted? ? model.has_scope?(scope[:name]) : false - input( - type: "checkbox", - name: "program[scopes_array][]", - value: scope[:name], - id: "program_scopes_#{scope[:name]}", - checked: scope_checked, - ) - label(for: "program_scopes_#{scope[:name]}", class: "checkbox-label", style: "margin-right: 0.5rem;") { scope[:name] } - small { scope[:description] } - end - end - end - end - - submit model.new_record? ? "Create Program" : "Update Program" - end -end diff --git a/config/locales/en.yml b/config/locales/en.yml index aeb1575f..1e4bfd07 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -151,17 +151,23 @@ en: backend: Admin developer_apps: index: - title: Developers' corner + title: Your Apps + title_admin: All Apps create_new: app me up! no_scopes: No scopes edit: Edit - delete: Delete - delete_confirm: Are you sure you want to delete this app? This cannot be undone. - rotate_credentials: Rotate Secret & API Key - rotate_confirm: Are you sure? This will invalidate your current client secret and API key, breaking any existing integrations using those credentials. - rotate_hint: If your credentials have been compromised, you can rotate them to generate new ones. Just make sure to update your integration with the new credentials afterward! client_id: Client ID click_to_copy_client_id: click to copy client ID + collaborators_count: + one: "%{count} collaborator" + other: "%{count} collaborators" + search_label: Search + search_placeholder: Search by app name... + search_button: Search + clear_filters: Clear + results_count: + one: "1 app" + other: "%{count} apps" blank_slate: title: No OAuth apps yet cta: Create an app to start authenticating with Hack Club! @@ -169,6 +175,16 @@ en: not_found: "App not found (or you're unauthorized?)" show: back_to_apps: ← Back to Apps + details: Details + owner: Owner + status: Status + active: Active + inactive: Inactive + trust_level: Trust Level + scopes: Scopes + no_scopes: No scopes selected + user_count: Users + onboarding_scenario: Onboarding Scenario security_warning_title: Keep your credentials secure! security_warning_message: Never share your client secret publicly or commit it to version control. oauth_credentials: OAuth Credentials @@ -176,32 +192,44 @@ en: click_to_copy_client_id: click to copy client ID client_secret: Client Secret click_to_copy_client_secret: click to copy client secret + reveal: Reveal + hide: Hide + api_key: API Key + click_to_copy_api_key: click to copy API key redirect_uris: Redirect URIs test_auth: Test Auth auth_link_hint: Right click auth links to copy URL - configuration: Configuration - scopes: Scopes - no_scopes: No scopes selected - trust_level: Trust Level + collaborators: Collaborators + no_collaborators: No collaborators yet. + add_collaborator: Add a collaborator + collaborator_email_placeholder: collaborator@example.com + add_button: Add + remove_collaborator: Remove + remove_collaborator_confirm: Remove this collaborator? edit_app: Edit App delete_app: Delete App delete_confirm: Are you sure you want to delete this app? This action cannot be undone and will revoke all existing tokens. - rotate_credentials: Rotate Secret & API Key + rotate_credentials: Rotate Credentials rotate_confirm: Are you sure? This will generate a new client secret and API key. The old ones will stop working immediately. - rotate_hint: If your credentials have been compromised, rotate them here. + danger_zone: Danger Zone + revoke_all_authorizations: Revoke All Tokens + revoke_all_authorizations_confirm: "Revoke all active tokens for this app? Users will be signed out and need to re-authorize." new: title: Create New OAuth App back_to_apps: ← Back to Apps errors_header: one: "%{count} issue prevented this from being saved:" other: "%{count} issues prevented this from being saved:" + app_details_heading: App Details app_name: Application Name app_name_placeholder: Northern California National Bank Online Banking redirect_uri: Redirect URI(s) redirect_uri_placeholder: http://localhost:3000/oauth/callback redirect_uri_hint: Enter one redirect URI per line for OAuth callbacks - scopes: Scopes - scopes_hint: "Community apps are limited to: %{scopes}" + trust_level_heading: Trust Level + trust_level: Trust Level + trust_level_hint: Controls which scopes the app may request and how the consent screen appears to users. + oauth_scopes_heading: OAuth Scopes trust_level_note_html: "Note: Your app will initially be created with community_untrusted trust level.
This means users will see a scarier consent screen when authorizing your app.
Once you've developed your integration, poke Nora to get it promoted." create: Create App cancel: Cancel @@ -211,11 +239,22 @@ en: errors_header: one: "%{count} issue prevented this from being saved:" other: "%{count} issues prevented this from being saved:" + app_details_heading: App Details app_name: Application Name redirect_uri: Redirect URI redirect_uri_hint: Enter one redirect URI per line for OAuth callbacks - scopes: Scopes - scopes_hint: "Community apps are limited to: %{scopes}" + trust_level_heading: Trust Level + trust_level: Trust Level + trust_level_hint: Controls which scopes the app may request and how the consent screen appears to users. + oauth_scopes_heading: OAuth Scopes + scopes_removed: "Scopes removed:" + 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 + onboarding_scenario: Onboarding Scenario + onboarding_default: "(default)" + onboarding_scenario_hint: Users signing up through this OAuth app will use this onboarding flow + active: Active update: Update App cancel: Cancel create: @@ -226,10 +265,17 @@ en: success: OAuth app deleted successfully! rotate_credentials: success: OAuth app credentials rotated successfully! - require_developer_mode: - developer_mode_required: Developer mode is not enabled for your account. + revoke_all_authorizations: + success: + one: "Revoked 1 token." + other: "Revoked %{count} tokens." set_app: not_found: OAuth app not found. + developer_app_collaborators: + create: + generic_response: "If an account with this email exists, they've been added as a collaborator." + destroy: + success: Collaborator removed. addresses: first_name: First name last_name: Last name diff --git a/config/routes.rb b/config/routes.rb index 96725605..b6f60636 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -226,11 +226,7 @@ def self.matches?(request) end end - resources :programs do - member do - post :rotate_credentials - end - end + # Programs management moved to DeveloperAppsController (unified UI) post "/break_glass", to: "break_glass#create" @@ -364,7 +360,10 @@ def self.matches?(request) resources :developer_apps, path: "developer/apps" do member do post :rotate_credentials + post :revoke_all_authorizations end + resources :collaborators, only: [ :create, :destroy ], + controller: "developer_app_collaborators" end diff --git a/db/analytics_schema.rb b/db/analytics_schema.rb new file mode 100644 index 00000000..3f11b30c --- /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/20260226200000_add_can_hq_officialize_to_identities.rb b/db/migrate/20260226200000_add_can_hq_officialize_to_identities.rb new file mode 100644 index 00000000..99679822 --- /dev/null +++ b/db/migrate/20260226200000_add_can_hq_officialize_to_identities.rb @@ -0,0 +1,5 @@ +class AddCanHqOfficializeToIdentities < ActiveRecord::Migration[8.0] + def change + add_column :identities, :can_hq_officialize, :boolean, default: false, null: false + end +end diff --git a/db/migrate/20260226200001_create_program_collaborators.rb b/db/migrate/20260226200001_create_program_collaborators.rb new file mode 100644 index 00000000..5fc8028b --- /dev/null +++ b/db/migrate/20260226200001_create_program_collaborators.rb @@ -0,0 +1,11 @@ +class CreateProgramCollaborators < ActiveRecord::Migration[8.0] + def change + create_table :program_collaborators do |t| + t.references :program, null: false, foreign_key: { to_table: :oauth_applications } + t.references :identity, null: false, foreign_key: true + t.timestamps + end + + add_index :program_collaborators, [:program_id, :identity_id], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 87fdef8a..cab71e3f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_02_18_200000) do +ActiveRecord::Schema[8.0].define(version: 2026_02_26_200001) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pgcrypto" @@ -301,8 +301,8 @@ 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)" t.index ["aadhaar_number_bidx"], name: "index_identities_on_aadhaar_number_bidx", unique: true t.index ["deleted_at"], name: "index_identities_on_deleted_at" @@ -446,6 +446,7 @@ 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 @@ -526,6 +527,16 @@ t.index ["access_grant_id"], name: "index_oauth_openid_requests_on_access_grant_id" end + create_table "program_collaborators", force: :cascade do |t| + t.bigint "program_id", null: false + t.bigint "identity_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["identity_id"], name: "index_program_collaborators_on_identity_id" + t.index ["program_id", "identity_id"], name: "index_program_collaborators_on_program_id_and_identity_id", unique: true + t.index ["program_id"], name: "index_program_collaborators_on_program_id" + end + create_table "settings", force: :cascade do |t| t.string "key", null: false t.text "value" @@ -587,18 +598,6 @@ 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" @@ -628,8 +627,9 @@ add_foreign_key "oauth_access_tokens", "identities", column: "resource_owner_id" add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id" add_foreign_key "oauth_openid_requests", "oauth_access_grants", column: "access_grant_id", on_delete: :cascade + add_foreign_key "program_collaborators", "identities" + add_foreign_key "program_collaborators", "oauth_applications", column: "program_id" 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/factories/backend_users.rb b/spec/factories/backend_users.rb new file mode 100644 index 00000000..ed9d2cec --- /dev/null +++ b/spec/factories/backend_users.rb @@ -0,0 +1,25 @@ +FactoryBot.define do + factory :backend_user, class: "Backend::User" do + sequence(:username) { |n| "admin#{n}" } + active { true } + super_admin { false } + program_manager { false } + manual_document_verifier { false } + human_endorser { false } + all_fields_access { false } + can_break_glass { false } + association :identity + + trait :super_admin do + super_admin { true } + end + + trait :program_manager do + program_manager { true } + end + + trait :mdv do + manual_document_verifier { true } + end + end +end diff --git a/spec/factories/identities.rb b/spec/factories/identities.rb index c5308980..192a3388 100644 --- a/spec/factories/identities.rb +++ b/spec/factories/identities.rb @@ -17,5 +17,13 @@ identity.update(primary_address: address) end end + + trait :can_hq_officialize do + can_hq_officialize { true } + end + + trait :developer do + developer_mode { true } + end end end diff --git a/spec/factories/program_collaborators.rb b/spec/factories/program_collaborators.rb new file mode 100644 index 00000000..de6829be --- /dev/null +++ b/spec/factories/program_collaborators.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :program_collaborator do + association :program + association :identity + end +end From e96c40f7d25303d82e04c6b88296debcdb726e28 Mon Sep 17 00:00:00 2001 From: 24c02 <163450896+24c02@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:07:35 -0500 Subject: [PATCH 02/30] lemme do it --- app/policies/program_policy.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/policies/program_policy.rb b/app/policies/program_policy.rb index f74b5931..6b49eabe 100644 --- a/app/policies/program_policy.rb +++ b/app/policies/program_policy.rb @@ -80,7 +80,7 @@ def revoke_all_authorizations? end def manage_collaborators? - owner? + owner? || admin? end class Scope < ApplicationPolicy::Scope From 4767d165c5b8c5ef0e096872e858adb0758f0c59 Mon Sep 17 00:00:00 2001 From: 24c02 <163450896+24c02@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:12:35 -0500 Subject: [PATCH 03/30] nope --- db/analytics_schema.rb | 52 ------------------------------------------ 1 file changed, 52 deletions(-) delete mode 100644 db/analytics_schema.rb diff --git a/db/analytics_schema.rb b/db/analytics_schema.rb deleted file mode 100644 index 3f11b30c..00000000 --- a/db/analytics_schema.rb +++ /dev/null @@ -1,52 +0,0 @@ -# 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 From 226c9f1926d709977099b25bdc3cf490969fd39a Mon Sep 17 00:00:00 2001 From: 24c02 <163450896+24c02@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:21:46 -0500 Subject: [PATCH 04/30] let them do it too --- app/policies/program_policy.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/policies/program_policy.rb b/app/policies/program_policy.rb index 6b49eabe..bb3852f2 100644 --- a/app/policies/program_policy.rb +++ b/app/policies/program_policy.rb @@ -64,7 +64,7 @@ def update_active? end def view_secret? - owner? || admin? + owner? || admin? || collaborator? end def view_api_key? @@ -72,7 +72,7 @@ def view_api_key? end def rotate_credentials? - owner? || admin? + owner? || admin? || collaborator? end def revoke_all_authorizations? From 1247f88781831c1e2377297feaf40d7c2f62128e Mon Sep 17 00:00:00 2001 From: 24c02 <163450896+24c02@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:46:03 -0500 Subject: [PATCH 05/30] collab invite model --- ...app_collaborator_invitations_controller.rb | 35 +++++++++++++++++++ .../developer_app_collaborators_controller.rb | 9 +++-- app/controllers/developer_apps_controller.rb | 7 +++- app/models/identity.rb | 10 +++++- app/models/program.rb | 5 +-- app/models/program_collaborator.rb | 29 +++++++++++++-- app/views/developer_apps/index.html.erb | 10 ++++++ app/views/developer_apps/show.html.erb | 13 ++++++- config/locales/en.yml | 14 +++++++- config/routes.rb | 7 ++++ ...001_add_status_to_program_collaborators.rb | 14 ++++++++ ..._null_identity_on_program_collaborators.rb | 8 +++++ db/schema.rb | 8 +++-- spec/factories/program_collaborators.rb | 6 ++++ 14 files changed, 162 insertions(+), 13 deletions(-) create mode 100644 app/controllers/developer_app_collaborator_invitations_controller.rb create mode 100644 db/migrate/20260302000001_add_status_to_program_collaborators.rb create mode 100644 db/migrate/20260302000002_allow_null_identity_on_program_collaborators.rb diff --git a/app/controllers/developer_app_collaborator_invitations_controller.rb b/app/controllers/developer_app_collaborator_invitations_controller.rb new file mode 100644 index 00000000..8c907563 --- /dev/null +++ b/app/controllers/developer_app_collaborator_invitations_controller.rb @@ -0,0 +1,35 @@ +class DeveloperAppCollaboratorInvitationsController < ApplicationController + include IdentityAuthorizable + + before_action :set_app + skip_after_action :verify_authorized, only: %i[accept decline] + + # Invitee accepts + def accept + invitation = current_identity.pending_collaboration_invitations.find(params[:id]) + invitation.update!(identity: current_identity) if invitation.identity_id.nil? + invitation.accept! + redirect_to developer_apps_path, notice: t(".success") + end + + # Invitee declines + def decline + invitation = current_identity.pending_collaboration_invitations.find(params[:id]) + invitation.decline! + redirect_to developer_apps_path, notice: t(".success") + end + + # Owner cancels + def cancel + authorize @app, :manage_collaborators? + invitation = @app.program_collaborators.pending.find(params[:id]) + invitation.cancel! + redirect_to developer_app_path(@app), notice: t(".success") + end + + private + + def set_app + @app = Program.find(params[:developer_app_id]) + end +end diff --git a/app/controllers/developer_app_collaborators_controller.rb b/app/controllers/developer_app_collaborators_controller.rb index a9371e34..59fbce8b 100644 --- a/app/controllers/developer_app_collaborators_controller.rb +++ b/app/controllers/developer_app_collaborators_controller.rb @@ -8,11 +8,14 @@ def create email = params[:email].to_s.strip.downcase - # Anti-enumeration: always return the same generic message regardless of outcome + # Anti-enumeration: always create a pending record regardless of whether + # the identity exists. The owner sees the same "Pending" row either way. identity = Identity.find_by(primary_email: email) - if identity && identity != @app.owner_identity - @app.program_collaborators.find_or_create_by(identity: identity) + unless identity&.id == @app.owner_identity_id + @app.program_collaborators.find_or_create_by(invited_email: email) do |pc| + pc.identity = identity + end end redirect_to developer_app_path(@app), notice: t(".generic_response") diff --git a/app/controllers/developer_apps_controller.rb b/app/controllers/developer_apps_controller.rb index cb70899d..777618c7 100644 --- a/app/controllers/developer_apps_controller.rb +++ b/app/controllers/developer_apps_controller.rb @@ -16,12 +16,17 @@ def index end @apps = @apps.page(params[:page]).per(25) + + @pending_invitations = current_identity.pending_collaboration_invitations end def show authorize @app @identities_count = @app.identities.distinct.count - @collaborators = @app.program_collaborators.includes(:identity) if policy(@app).manage_collaborators? + if policy(@app).manage_collaborators? + @collaborators = @app.program_collaborators.accepted.includes(:identity) + @pending_invitations_for_app = @app.program_collaborators.pending + end end def new diff --git a/app/models/identity.rb b/app/models/identity.rb index c12e20b5..845b1a7b 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -84,7 +84,8 @@ def active_for_backend? has_many :owned_developer_apps, class_name: "Program", foreign_key: :owner_identity_id, dependent: :nullify has_many :program_collaborators, dependent: :destroy - has_many :collaborated_programs, through: :program_collaborators, source: :program + has_many :collaborated_programs, -> { merge(ProgramCollaborator.accepted) }, + through: :program_collaborators, source: :program validates :first_name, :last_name, :country, :primary_email, :birthday, presence: true validates :primary_email, uniqueness: { conditions: -> { where(deleted_at: nil) } } @@ -175,6 +176,13 @@ def self.link_slack_account(code, redirect_uri, current_identity) { success: true, slack_id: slack_id } end + def pending_collaboration_invitations + ProgramCollaborator.pending + .where(identity_id: id) + .or(ProgramCollaborator.pending.where(identity_id: nil, invited_email: primary_email)) + .includes(:program) + end + def accessible_developer_apps Program.where(id: owned_developer_apps.select(:id)) .or(Program.where(id: collaborated_programs.select(:id))) diff --git a/app/models/program.rb b/app/models/program.rb index f504bb83..48958602 100644 --- a/app/models/program.rb +++ b/app/models/program.rb @@ -44,7 +44,8 @@ class Program < ApplicationRecord has_many :organizers, through: :organizer_positions, source: :backend_user, class_name: "Backend::User" has_many :program_collaborators, dependent: :destroy - has_many :collaborator_identities, through: :program_collaborators, source: :identity + has_many :collaborator_identities, -> { merge(ProgramCollaborator.accepted) }, + through: :program_collaborators, source: :identity belongs_to :owner_identity, class_name: "Identity", optional: true @@ -100,7 +101,7 @@ def onboarding_scenario_instance(identity = nil) def collaborator?(identity) return false unless identity - program_collaborators.exists?(identity: identity) + program_collaborators.accepted.exists?(identity: identity) end def accessible_by?(identity) diff --git a/app/models/program_collaborator.rb b/app/models/program_collaborator.rb index 6490de55..3541d58d 100644 --- a/app/models/program_collaborator.rb +++ b/app/models/program_collaborator.rb @@ -1,8 +1,33 @@ # frozen_string_literal: true class ProgramCollaborator < ApplicationRecord + include AASM + belongs_to :program - belongs_to :identity + belongs_to :identity, optional: true + + validates :invited_email, presence: true + validates :invited_email, uniqueness: { scope: :program_id, conditions: -> { visible } } + validates :identity_id, uniqueness: { scope: :program_id }, allow_nil: true + + scope :visible, -> { where(status: %w[pending accepted]) } + + aasm column: :status, timestamps: true do + state :pending, initial: true + state :accepted + state :declined + state :cancelled + + event :accept do + transitions from: :pending, to: :accepted + end + + event :decline do + transitions from: :pending, to: :declined + end - validates :identity_id, uniqueness: { scope: :program_id } + event :cancel do + transitions from: :pending, to: :cancelled + end + end end diff --git a/app/views/developer_apps/index.html.erb b/app/views/developer_apps/index.html.erb index 3f0a0607..633102ba 100644 --- a/app/views/developer_apps/index.html.erb +++ b/app/views/developer_apps/index.html.erb @@ -6,6 +6,16 @@ +<% if @pending_invitations.any? %> + <% @pending_invitations.each do |invitation| %> + <%= render Components::Banner.new(kind: :info) do %> + <%= t(".invitation_banner", app_name: invitation.program.name) %> + <%= button_to t(".accept_invitation"), accept_developer_app_collaborator_invite_path(invitation.program, invitation), method: :post, class: "small-btn" %> + <%= button_to t(".decline_invitation"), decline_developer_app_collaborator_invite_path(invitation.program, invitation), method: :post, class: "small-btn danger" %> + <% end %> + <% end %> +<% end %> +
<% if admin? %>