diff --git a/app/components/bootleg_turbo_button.rb b/app/components/bootleg_turbo_button.rb new file mode 100644 index 0000000..45a8607 --- /dev/null +++ b/app/components/bootleg_turbo_button.rb @@ -0,0 +1,23 @@ +class Components::BootlegTurboButton < Components::Base + def initialize(path, text:, **opts) + @path = path + @text = text + @opts = opts + end + + def view_template + container_id = @opts.delete(:id) || "btb-#{@path.parameterize}" + div(id: container_id, class: "btb-container") do + button( + class: "secondary small-btn", + hx_get: @path, + hx_target: "##{container_id}", + hx_swap: "innerHTML", + **@opts + ) { @text } + div(class: "hx-loader") do + vite_image_tag "images/loader.gif", style: "image-rendering: pixelated;" + end + end + end +end diff --git a/app/components/identity_mention.rb b/app/components/identity_mention.rb new file mode 100644 index 0000000..5ee5098 --- /dev/null +++ b/app/components/identity_mention.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class Components::IdentityMention < Components::Base + register_value_helper :current_identity + + def initialize(identity) + @identity = identity + end + + def view_template + if @identity.nil? + i { "System" } + return + end + + span(class: "identity-mention") do + plain display_name + + if @identity.backend_user + plain " " + abbr(title: "#{@identity.first_name} is an admin") { "⚡" } + end + end + end + + private + + def display_name + if @identity == current_identity + "You" + elsif current_identity&.backend_user + "#{@identity.first_name} #{@identity.last_name}" + else + @identity.first_name + end + end +end diff --git a/app/components/public_activity/snippet.rb b/app/components/public_activity/snippet.rb index b5d9aca..9a0f7bf 100644 --- a/app/components/public_activity/snippet.rb +++ b/app/components/public_activity/snippet.rb @@ -1,24 +1,29 @@ # frozen_string_literal: true class Components::PublicActivity::Snippet < Components::Base - def initialize(activity, owner: nil) + def initialize(activity, owner: nil, owner_component: nil) @activity = activity @owner = owner + @owner_component = owner_component end def view_template tr do td do - owner = @owner || @activity.owner - - if owner.nil? - i { "System" } - elsif owner.is_a?(::Backend::User) - render Components::UserMention.new(owner) - elsif owner.is_a?(::Identity) - render Components::UserMention.new(owner) + if @owner_component + render @owner_component else - render owner + owner = @owner || @activity.owner + + if owner.nil? + i { "System" } + elsif owner.is_a?(::Backend::User) + render Components::UserMention.new(owner) + elsif owner.is_a?(::Identity) + render Components::UserMention.new(owner) + else + render owner + end end end td { yield } diff --git a/app/components/sidebar.rb b/app/components/sidebar.rb index ec42f6b..a81ccf4 100644 --- a/app/components/sidebar.rb +++ b/app/components/sidebar.rb @@ -50,9 +50,10 @@ class Components::Sidebar < Components::Base 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? - items << { label: t("sidebar.developer"), path: developer_apps_path, icon: "code" } + # 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?) + pending_count = current_identity.pending_collaboration_invitations.count + items << { label: t("sidebar.developer"), path: developer_apps_path, icon: "code", badge: pending_count } end items << { label: t("sidebar.docs"), path: docs_path, icon: "docs" } @@ -95,12 +96,13 @@ class Components::Sidebar < Components::Base end end - def render_nav_item(label:, path:, icon: nil) + def render_nav_item(label:, path:, icon: nil, badge: nil) is_active = @current_path == path link_to(path, class: [ "sidebar-nav-item", ("active" if is_active) ].compact.join(" ")) do span(class: "nav-icon") { inline_icon(icon, size: 24) } if icon span(class: "nav-label") { label } + span(class: "nav-badge") { badge.to_s } if badge && badge > 0 end end diff --git a/app/controllers/backend/identities_controller.rb b/app/controllers/backend/identities_controller.rb index 5051e00..35530e7 100644 --- a/app/controllers/backend/identities_controller.rb +++ b/app/controllers/backend/identities_controller.rb @@ -53,6 +53,9 @@ module Backend @all_programs = @identity.all_programs.distinct + @owned_apps = @identity.owned_developer_apps + @collaborated_apps = @identity.collaborated_programs + verification_ids = @identity.verifications.pluck(:id) document_ids = @identity.documents.pluck(:id) break_glass_record_ids = BreakGlassRecord.where(break_glassable_type: "Identity::Document", break_glassable_id: document_ids).pluck(:id) @@ -182,7 +185,9 @@ module Backend 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 4b5c48b..4eec40b 100644 --- a/app/controllers/backend/kbar_controller.rb +++ b/app/controllers/backend/kbar_controller.rb @@ -60,7 +60,7 @@ module Backend 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 0f77c73..0000000 --- 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 0000000..511a4de --- /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_collaborator_invitations_controller.rb b/app/controllers/developer_app_collaborator_invitations_controller.rb new file mode 100644 index 0000000..5b616fa --- /dev/null +++ b/app/controllers/developer_app_collaborator_invitations_controller.rb @@ -0,0 +1,47 @@ +class DeveloperAppCollaboratorInvitationsController < ApplicationController + include IdentityAuthorizable + + before_action :set_app + skip_after_action :verify_authorized, only: %i[accept decline] + + # Invitee accepts + def accept + invitation = @app.program_collaborators.pending.where( + "(identity_id = ? OR (identity_id IS NULL AND invited_email = ?))", + current_identity.id, + current_identity.primary_email + ).find(params[:id]) + invitation.update!(identity: current_identity) if invitation.identity_id.nil? + invitation.accept! + @app.create_activity :collaborator_accepted, owner: current_identity + redirect_to developer_apps_path, notice: t(".success") + end + + # Invitee declines + def decline + invitation = @app.program_collaborators.pending.where( + "(identity_id = ? OR (identity_id IS NULL AND invited_email = ?))", + current_identity.id, + current_identity.primary_email + ).find(params[:id]) + invitation.decline! + @app.create_activity :collaborator_declined, owner: current_identity + 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]) + email = invitation.invited_email + invitation.cancel! + @app.create_activity :collaborator_cancelled, owner: current_identity, parameters: { cancelled_email: email } + 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 new file mode 100644 index 0000000..d31d204 --- /dev/null +++ b/app/controllers/developer_app_collaborators_controller.rb @@ -0,0 +1,63 @@ +class DeveloperAppCollaboratorsController < ApplicationController + include IdentityAuthorizable + + before_action :set_app + + def create + authorize @app, :manage_collaborators? + + email = params[:email].to_s.strip.downcase + + if email == @app.owner_identity&.primary_email + redirect_to developer_app_path(@app), alert: t(".cannot_add_self") + return + end + + identity = Identity.find_by(primary_email: email) + + unless identity&.id == @app.owner_identity_id + collaborator = @app.program_collaborators.find_or_create_by(invited_email: email) do |pc| + pc.identity = identity + end + + unless collaborator.persisted? + alert_message = collaborator.errors.full_messages.to_sentence.presence || t(".invalid_email") + redirect_to developer_app_path(@app), alert: alert_message + return + end + + reinvited = collaborator.declined? || collaborator.cancelled? + collaborator.update!(status: :pending, identity: identity) if reinvited + + if collaborator.previously_new_record? || reinvited + @app.create_activity :collaborator_invited, owner: current_identity, parameters: { invited_email: email } + redirect_to developer_app_path(@app), notice: t(".invited") + else + redirect_to developer_app_path(@app), alert: t(".already_invited") + end + return + end + + redirect_to developer_app_path(@app), notice: t(".invited") + end + + def destroy + authorize @app, :manage_collaborators? + + collaborator = @app.program_collaborators.find(params[:id]) + email = collaborator.invited_email + collaborator.destroy + @app.create_activity :collaborator_removed, owner: current_identity, parameters: { removed_email: email } + + redirect_to developer_app_path(@app), notice: t(".success") + 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 74a486b..e4fea29 100644 --- a/app/controllers/developer_apps_controller.rb +++ b/app/controllers/developer_apps_controller.rb @@ -1,23 +1,62 @@ 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, :activity_log ] 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 = :uid", + q: "%#{params[:search]}%", + uid: params[:search] + ) + 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 + if policy(@app).manage_collaborators? + @collaborators = @app.program_collaborators.accepted.includes(:identity) + @pending_invitations_for_app = @app.program_collaborators.pending + end + end + + def activity_log + authorize @app + @activities = PublicActivity::Activity + .where(trackable: @app) + .includes(:owner) + .order(created_at: :desc) + .limit(50) + render layout: "htmx" 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 +65,24 @@ class DeveloperAppsController < ApplicationController end def edit + authorize @app end def update - if @app.update(app_params) + authorize @app + + snapshot = @app.audit_snapshot + 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 + changes = @app.audit_diff(snapshot) + if changes.any? + @app.create_activity :change, owner: current_identity, parameters: { changes: changes } + end redirect_to developer_app_path(@app), notice: t(".success") else render :edit, status: :unprocessable_entity @@ -37,6 +90,7 @@ class DeveloperAppsController < ApplicationController end def destroy + authorize @app app_name = @app.name @app.create_activity :destroy, owner: current_identity, parameters: { name: app_name } @app.destroy @@ -44,27 +98,69 @@ class DeveloperAppsController < ApplicationController end def rotate_credentials + authorize @app @app.rotate_credentials! + @app.create_activity :rotate_credentials, owner: current_identity redirect_to developer_app_path(@app), notice: t(".success") 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 + @app.create_activity :revoke_all_authorizations, owner: current_identity, parameters: { count: count } + redirect_to developer_app_path(@app), notice: t(".success", count: count) + end + private - def require_developer_mode - unless current_identity.developer_mode? - flash[:error] = t(".require_developer_mode") - redirect_to root_path - end - end - 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 2616eeb..70b0127 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 0000000..66ce296 --- /dev/null +++ b/app/frontend/js/scope-editor.js @@ -0,0 +1,55 @@ +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() { + // Determine which scopes are valid for the new trust level, + // scoped to what this user is allowed to touch. + const trustValid = this.trustLevel === 'hq_official' ? allowedScopes : communityScopes; + this.removedScopes = this.selected.filter(s => + allowedScopes.includes(s) && !trustValid.includes(s) + ); + this.selected = this.selected.filter(s => + trustValid.includes(s) || !allowedScopes.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 c8dc7f8..6307d21 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/_utilities.scss b/app/frontend/stylesheets/snippets/_utilities.scss index 90c5d15..c9f84c2 100644 --- a/app/frontend/stylesheets/snippets/_utilities.scss +++ b/app/frontend/stylesheets/snippets/_utilities.scss @@ -5,3 +5,12 @@ .container { @include container(); } .container-md { @include container(1000px); } .container-sm { @include container(720px); } + +// HTMX loader — hidden by default, shown when a sibling triggers a request +.hx-loader { + display: none; + margin-top: $space-3; +} +.htmx-request ~ .hx-loader { + display: block; +} diff --git a/app/frontend/stylesheets/snippets/banners.scss b/app/frontend/stylesheets/snippets/banners.scss index e9b2d31..b30ed73 100644 --- a/app/frontend/stylesheets/snippets/banners.scss +++ b/app/frontend/stylesheets/snippets/banners.scss @@ -85,4 +85,24 @@ &.purple { @include banner-purple; } +} + +.banner-actions { + display: flex; + gap: 0.75rem; + flex-shrink: 0; + margin-left: auto; + form { display: inline; } + + @media (max-width: 768px) { + margin-left: 0; + margin-top: 0.75rem; + } +} + +.banner:has(.banner-actions) > .grow { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; } \ No newline at end of file diff --git a/app/frontend/stylesheets/snippets/developer_apps.scss b/app/frontend/stylesheets/snippets/developer_apps.scss new file mode 100644 index 0000000..55e0f29 --- /dev/null +++ b/app/frontend/stylesheets/snippets/developer_apps.scss @@ -0,0 +1,357 @@ +// 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; + .button-secondary { margin: 0; } +} + +// 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 { + @extend .label-upper; + margin-bottom: $space-2; + } + form { + display: flex; + gap: $space-2; + align-items: center; + input[type="email"] { flex: 1; margin: 0; } + button { padding-block: var(--pico-form-element-spacing-vertical); line-height: normal; margin: 0; } + } +} + +// 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 { @extend .label-upper; } + .meta-value { font-size: 0.9375rem; } +} + +// Credential rows — divider-separated +.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 { @extend .label-upper; display: block; margin-bottom: 0.3rem; } +} + +.credential-value { + font-family: $font-mono; + font-size: 0.875rem; + color: var(--pico-color); + word-break: break-all; + cursor: pointer; + &.credential-masked { + color: var(--pico-muted-color); + letter-spacing: 0.1em; + } +} + +.cred-reveal-row { + display: flex; + align-items: center; + gap: $space-2; + .pointer { flex: 1; min-width: 0; } + 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; } + display: flex; + flex-direction: column; + gap: $space-2; + form { display: contents; } +} + +// 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: flex; + align-items: center; + gap: $space-3; + flex: 1; + min-width: 0; + } + input[type="search"], input[type="text"] { + flex: 1; min-width: 200px; + margin: 0; padding: 0.5rem 0.75rem 0.5rem 2.5rem; 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; padding: 0.5rem 1rem; white-space: nowrap; } +} + +// 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 { + @extend .label-upper; + 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; } +} + +// Activity log table (frontend equivalent of backend table styles) +.dev-panel-main .table-container { + overflow-x: auto; + + table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; + + th { + text-align: left; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--pico-muted-color); + padding: $space-2 $space-3; + border-bottom: 2px solid var(--pico-muted-border-color); + } + + td { + padding: $space-2 $space-3; + border-bottom: 1px solid var(--pico-muted-border-color); + vertical-align: top; + } + + tr:hover td { + background: var(--pico-card-background-color); + } + } +} + +// Identity mention — inline actor display for frontend +.identity-mention { + font-weight: 600; + white-space: nowrap; + abbr { + text-decoration: none; + cursor: help; + } +} + +// 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/frontend/stylesheets/snippets/email_changes.scss b/app/frontend/stylesheets/snippets/email_changes.scss index e8687bf..05f462c 100644 --- a/app/frontend/stylesheets/snippets/email_changes.scss +++ b/app/frontend/stylesheets/snippets/email_changes.scss @@ -21,11 +21,7 @@ } } .email-label { - font-size: 0.75rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--text-muted-strong); + @extend .label-upper; margin-bottom: 0.25rem; } .email-item { diff --git a/app/frontend/stylesheets/snippets/sidebar.scss b/app/frontend/stylesheets/snippets/sidebar.scss index cbfab3f..17c679c 100644 --- a/app/frontend/stylesheets/snippets/sidebar.scss +++ b/app/frontend/stylesheets/snippets/sidebar.scss @@ -135,6 +135,22 @@ font-size: 0.95rem; letter-spacing: -0.01em; } + + .nav-badge { + margin-left: auto; + min-width: 20px; + height: 20px; + padding: 0 6px; + border-radius: 10px; + background: color-mix(in srgb, var(--pico-primary) 70%, grey); + color: white; + font-size: 0.7rem; + font-weight: 700; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; + } } .sidebar-user { diff --git a/app/frontend/stylesheets/snippets/skeumorphic.scss b/app/frontend/stylesheets/snippets/skeumorphic.scss index 9ff728e..b4c32e7 100644 --- a/app/frontend/stylesheets/snippets/skeumorphic.scss +++ b/app/frontend/stylesheets/snippets/skeumorphic.scss @@ -209,6 +209,45 @@ color: $fg; } +// Generic uppercase muted label — section dividers, field labels, meta labels +.label-upper { + font-size: 0.8rem; + font-weight: 600; + color: var(--text-muted-strong); + text-transform: uppercase; + letter-spacing: 0.04em; + margin: 0; +} + +.badge { + @include badge(var(--surface-2), var(--text-muted-strong)); + border: 1px solid var(--surface-2-border); + + &.success { + background: var(--success-bg); + color: var(--success-fg-strong); + border-color: var(--success-border); + } + + &.warning, &.pending { + background: var(--warning-bg); + color: var(--warning-fg-strong); + border-color: var(--warning-border); + } + + &.info { + background: var(--info-bg); + color: var(--info-fg-strong); + border-color: var(--info-border); + } + + &.danger { + background: var(--error-bg); + color: var(--error-fg-strong); + border-color: var(--error-border); + } +} + @mixin page-title($size: 1.25rem, $mb: 1.25rem) { font-size: $size; font-weight: 600; diff --git a/app/lib/shortcodes.rb b/app/lib/shortcodes.rb index 68d1e3e..d9489c8 100644 --- a/app/lib/shortcodes.rb +++ b/app/lib/shortcodes.rb @@ -31,9 +31,9 @@ module Shortcodes # 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/concerns/auditable.rb b/app/models/concerns/auditable.rb new file mode 100644 index 0000000..b4ce924 --- /dev/null +++ b/app/models/concerns/auditable.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Auditable + extend ActiveSupport::Concern + + included do + class_attribute :auditable_fields, default: {} + end + + class_methods do + # Declare fields to track. Types: :scalar (default), :array, :boolean + # label: human-readable name for the field + # transform: optional lambda to format values for display + def audit_field(name, type: :scalar, label: nil, transform: nil) + self.auditable_fields = auditable_fields.merge( + name => { type: type, label: label || name.to_s.humanize, transform: transform } + ) + end + end + + # Snapshot current values for declared fields + def audit_snapshot + auditable_fields.each_with_object({}) do |(name, _config), hash| + val = send(name) + hash[name] = val.is_a?(Array) ? val.dup : val + end + end + + # Compute structured diff between snapshot and current state + def audit_diff(snapshot) + changes = {} + auditable_fields.each do |name, config| + old_val = snapshot[name] + new_val = send(name) + + case config[:type] + when :array + old_arr = Array(old_val) + new_arr = Array(new_val) + added = new_arr - old_arr + removed = old_arr - new_arr + next if added.empty? && removed.empty? + changes[name] = { added: added, removed: removed } + when :boolean + next if old_val == new_val + changes[name] = { from: old_val, to: new_val } + else # :scalar + next if old_val == new_val + transform = config[:transform] + changes[name] = { + from: transform&.call(old_val) || old_val, + to: transform&.call(new_val) || new_val + } + end + end + changes + end +end diff --git a/app/models/identity.rb b/app/models/identity.rb index 3f1527d..0842284 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -83,6 +83,10 @@ class Identity < ApplicationRecord 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, -> { 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) } } validate :validate_primary_email, if: -> { new_record? || primary_email_changed? } @@ -172,6 +176,18 @@ class Identity < ApplicationRecord { 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))) + 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 7723aca..e6cfdd1 100644 --- a/app/models/oauth_scope.rb +++ b/app/models/oauth_scope.rb @@ -126,6 +126,9 @@ class OAuthScope 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 3cbd232..de2fbe6 100644 --- a/app/models/program.rb +++ b/app/models/program.rb @@ -24,7 +24,24 @@ class Program < ApplicationRecord self.table_name = "oauth_applications" include PublicActivity::Model - tracked owner: ->(controller, model) { model.owner_identity }, recipient: ->(controller, model) { model.owner_identity }, only: [ :create, :update, :destroy ] + tracked owner: ->(controller, _model) { controller&.user_for_public_activity }, only: [ :create ] + + include Auditable + + audit_field :name, label: "app name" + audit_field :trust_level, transform: ->(v) { v.to_s.titleize } + audit_field :scopes_array, type: :array, label: "scopes" + audit_field :redirect_uris, type: :array, label: "redirect URIs" + audit_field :active, type: :boolean + audit_field :onboarding_scenario, transform: ->(v) { v&.titleize } + + COLLABORATOR_ACTIVITY_KEYS = %w[ + program.collaborator_invited + program.collaborator_removed + program.collaborator_accepted + program.collaborator_declined + program.collaborator_cancelled + ].freeze has_paper_trail @@ -43,6 +60,10 @@ 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, -> { merge(ProgramCollaborator.accepted) }, + through: :program_collaborators, source: :identity + belongs_to :owner_identity, class_name: "Identity", optional: true validates :name, presence: true @@ -82,6 +103,10 @@ class Program < ApplicationRecord self.scopes = Doorkeeper::OAuth::Scopes.from_array(Array(array).reject(&:blank?)).to_s end + def redirect_uris + redirect_uri.to_s.split + end + def has_scope?(scope_name) = scopes.include?(scope_name.to_s) def authorized_for_identity?(identity) = authorized_tokens.exists?(resource_owner: identity) @@ -95,6 +120,16 @@ class Program < ApplicationRecord onboarding_scenario_class&.new(identity) end + def collaborator?(identity) + return false unless identity + program_collaborators.accepted.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 0000000..f523280 --- /dev/null +++ b/app/models/program_collaborator.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class ProgramCollaborator < ApplicationRecord + include AASM + + belongs_to :program + belongs_to :identity, optional: true + + validates :invited_email, presence: true, 'valid_email_2/email': true + validates :invited_email, uniqueness: { scope: :program_id, conditions: -> { visible } } + validates :identity_id, uniqueness: { scope: :program_id, conditions: -> { visible } }, 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 + + event :cancel do + transitions from: :pending, to: :cancelled + end + end +end diff --git a/app/policies/program_policy.rb b/app/policies/program_policy.rb index adb2eee..dfe6df7 100644 --- a/app/policies/program_policy.rb +++ b/app/policies/program_policy.rb @@ -1,45 +1,125 @@ +# 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? = user_is_program_manager? || user_has_access_to_program? + def show? + owner? || collaborator? || admin? + end - def create? = user_is_program_manager? + def create? + user.developer_mode? || admin? + end - def update? = user_is_program_manager? || user_has_access_to_program? + def new? + create? + end - def destroy? = user_is_program_manager? + def update? + owner? || collaborator? || admin? + end - def update_basic_fields? = user_has_access_to_program? + def edit? + update? + end - def update_scopes? = user_is_program_manager? + def destroy? + owner? || admin? + end - def update_onboarding_scenario? = user&.super_admin? + def update_trust_level? + user.can_hq_officialize? || admin? + end - def rotate_credentials? = user_is_program_manager? + def update_scopes? + owner? || collaborator? || admin? + end - class Scope < Scope + def update_all_scopes? + admin? + end + + # 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_onboarding_scenario? + super_admin? + end + + def update_active? + admin? + end + + def view_secret? + owner? || admin? || collaborator? + end + + def view_api_key? + admin? + end + + def rotate_credentials? + owner? || admin? || collaborator? + end + + def revoke_all_authorizations? + owner? || admin? + end + + def activity_log? + show? + end + + def manage_collaborators? + owner? || admin? + end + + 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 user_has_assigned_programs? - user.present? && user.organized_programs.any? + def collaborator? + record.is_a?(Class) ? false : record.collaborator?(user) end - def user_has_access_to_program? - user_is_program_manager? || (user.present? && user.organized_programs.include?(record)) + def admin? + backend_user = user.backend_user + backend_user&.program_manager? || backend_user&.super_admin? + end + + 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 9f4cc6b..59d104f 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/identities/show.html.erb b/app/views/backend/identities/show.html.erb index 84b0ecc..00d31a4 100644 --- a/app/views/backend/identities/show.html.erb +++ b/app/views/backend/identities/show.html.erb @@ -144,7 +144,7 @@ <% @all_programs.each do |program| %> - <%= link_to program.name, backend_program_path(program) %> + <%= link_to program.name, developer_app_path(program) %> <%= program.scopes.presence || "—" %> <% end %> @@ -156,6 +156,39 @@ <% end %> +
+

developed apps

+
+ <% if @owned_apps.any? || @collaborated_apps.any? %> +
+ + + + + + + + + <% @owned_apps.each do |app| %> + + + + + <% end %> + <% @collaborated_apps.each do |app| %> + + + + + <% end %> + +
approle
<%= link_to app.name, developer_app_path(app) %> <%= app.trust_level.to_s.titleize %>owner
<%= link_to app.name, developer_app_path(app) %> <%= app.trust_level.to_s.titleize %>collaborator
+
+ <% else %> +
no developer apps
+ <% end %> +
+

audit log

diff --git a/app/views/backend/programs/_program.html.erb b/app/views/backend/programs/_program.html.erb deleted file mode 100644 index e3220aa..0000000 --- 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 11ba5f7..0000000 --- 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 ade33cd..0000000 --- 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 293fbd2..0000000 --- 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 9e3e927..0000000 --- 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 d4e74f1..0d54ae8 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/activity_log.html.erb b/app/views/developer_apps/activity_log.html.erb new file mode 100644 index 0000000..537f66c --- /dev/null +++ b/app/views/developer_apps/activity_log.html.erb @@ -0,0 +1,5 @@ +<% if @activities.any? %> + <%= render Components::PublicActivity::Container.new(@activities) %> +<% else %> +

<%= t("developer_apps.show.no_activity") %>

+<% end %> diff --git a/app/views/developer_apps/edit.html.erb b/app/views/developer_apps/edit.html.erb index 6cbaa5d..bc38342 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 === %> +
-
- <%= f.label :scopes_array, t(".scopes") %> - - <% Program::COMMUNITY_ALLOWED_SCOPES.each do |scope| %> - + <%# === 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 %> + + + <%# 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 f220ed2..9d39544 100644 --- a/app/views/developer_apps/index.html.erb +++ b/app/views/developer_apps/index.html.erb @@ -1,46 +1,67 @@ + +<% if @pending_invitations.any? %> + <% @pending_invitations.each do |invitation| %> + <%= render Components::Banner.new(kind: :info) do %> + <%= t(".invitation_banner", app_name: invitation.program.name) %> + + <% end %> + <% end %> +<% end %> + +
+ <% if admin? %> + + <% end %> + <% if @apps.any? %> -
- <%= link_to t(".create_new"), new_developer_app_path, role: "button" %> +
+ <% @apps.each do |app| %> +
+
+
+

<%= link_to app.name, developer_app_path(app) %>

+ <%= app.scopes_array.join(", ").presence || t(".no_scopes") %> +
+
+
+ <% if admin? && app.owner_identity %> + <%= app.owner_identity.full_name %> + <% end %> + <%= app.uid %> +
+
+ <% end %> +
+ <%= 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 %>
- -<% if @apps.any? %> - <% @apps.each do |app| %> -
-
-
-

<%= link_to app.name, developer_app_path(app) %>

-
- <%= app.scopes_array.join(", ").presence || t(".no_scopes") %> · - <%= app.trust_level.to_s.titleize %> -
-
-
- <%= link_to t(".edit"), edit_developer_app_path(app), class: "button-secondary", style: "font-size: 0.9rem; padding: 0.4rem 0.8rem;" %> - <%= button_to t(".delete"), developer_app_path(app), method: :delete, class: "danger small-btn", - form: { "hx-confirm": t(".delete_confirm") } %> -
-
- -
-
- <%= t ".client_id" %>: - <%= copy_to_clipboard app.uid, label: t(".click_to_copy_client_id") do %> - <%= app.uid %> - <% end %> -
-
-
- <% 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 dd2738e..186c783 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" %> +
+
+ + <%# === Cards 2+3 wrapped in Alpine === %> +
+ + <%# === 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 %> +
+
-
- <%= 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 :scopes_array, t(".scopes") %> - - <% Program::COMMUNITY_ALLOWED_SCOPES.each do |scope| %> - - <% end %> - <%= t ".scopes_hint", scopes: Program::COMMUNITY_ALLOWED_SCOPES.join(", ") %> -
-
-
- <%= 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 0960e86..274a9ec 100644 --- a/app/views/developer_apps/show.html.erb +++ b/app/views/developer_apps/show.html.erb @@ -2,73 +2,194 @@

<%= @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 %> -
+
+
-
-

<%= t ".configuration" %>

+ <%# Quick actions %> + <%= link_to t(".edit_app"), edit_developer_app_path(@app), class: "button-secondary" %> -
-
- -
- <% if @app.scopes_array.any? %> - <%= text_field_tag :scopes, @app.scopes_array.join(", "), readonly: true, autocomplete: "off" %> - <% else %> - <%= t ".no_scopes" %> + <%# 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 %> + + <%# OAuth Credentials %> +
+

<%= t ".oauth_credentials" %>

+
+ + <%= copy_to_clipboard @app.uid, label: t(".click_to_copy_client_id") do %> + <%= @app.uid %> <% end %>
-
+ <% if policy(@app).view_secret? %> +
+ +
+ <%= copy_to_clipboard @app.secret, label: t(".click_to_copy_client_secret") do %> + <%= @app.secret %> + [hidden] + <% end %> + +
+
+ <% end %> + <% if policy(@app).view_api_key? %> +
+ +
+ <%= copy_to_clipboard @app.program_key, label: t(".click_to_copy_api_key") do %> + <%= @app.program_key %> + [hidden] + <% end %> + +
+
+ <% end %> +
-
- - <%= text_field_tag :trust_level, @app.trust_level.to_s.titleize, readonly: true, autocomplete: "off" %> -
+ <%# Redirect URIs %> +
+

<%= t ".redirect_uris" %>

+
+ <% @app.redirect_uri.split.each do |uri| %> +
+ <%= copy_to_clipboard uri, label: t(".click_to_copy_redirect_uri") do %> + <%= uri %> + <% end %> + <%= 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 %> +
+
+ + <%# Collaborators (owner only) %> + <% if policy(@app).manage_collaborators? %> +
+

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

+
+ <% if @collaborators.any? %> + <% @collaborators.each do |collab| %> +
+ + <% if admin? %> + <%= link_to collab.identity.primary_email, backend_identity_path(collab.identity) %> + <% else %> + <%= collab.identity.primary_email %> + <% end %> + + <%= 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 %> + <% unless @pending_invitations_for_app.any? %> +

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

+ <% end %> + <% end %> +
+ + <% if @pending_invitations_for_app.any? %> +

+ <%= t(".pending_invitations_heading") %> +

+
+ <% @pending_invitations_for_app.each do |inv| %> +
+
+ <%= inv.invited_email %> + <%= t(".pending_badge") %> +
+ <%= button_to t(".cancel_invitation"), + cancel_developer_app_collaborator_invite_path(@app, inv), + method: :post, class: "secondary small-btn" %> +
+ <% end %> +
+ <% 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 %> + + <%# Activity Log (async) %> +
+

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

+ <%= render Components::BootlegTurboButton.new( + activity_log_developer_app_path(@app), + text: t(".show_activity"), + id: "activity-log-container" + ) %> +
- - -
- <%= 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") } %>
diff --git a/app/views/forms/backend/programs/form.rb b/app/views/forms/backend/programs/form.rb deleted file mode 100644 index f930b24..0000000 --- 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/app/views/public_activity/o_auth_token/_create.html.erb b/app/views/public_activity/o_auth_token/_create.html.erb index 67f1664..ffb6db3 100644 --- a/app/views/public_activity/o_auth_token/_create.html.erb +++ b/app/views/public_activity/o_auth_token/_create.html.erb @@ -2,7 +2,7 @@ <% app_name = activity.trackable&.application&.name || "(deleted app #{activity.trackable&.application_id})" app_link = if defined?(current_user) && current_user.is_a?(Backend::User) && activity.trackable&.application - link_to app_name, backend_program_path(activity.trackable.application) + link_to app_name, developer_app_path(activity.trackable.application) else app_name end diff --git a/app/views/public_activity/o_auth_token/_revoke.html.erb b/app/views/public_activity/o_auth_token/_revoke.html.erb index 004a457..93e4757 100644 --- a/app/views/public_activity/o_auth_token/_revoke.html.erb +++ b/app/views/public_activity/o_auth_token/_revoke.html.erb @@ -2,7 +2,7 @@ <% app_name = activity.trackable&.application&.name || "(deleted app #{activity.trackable&.application_id})" app_link = if defined?(current_user) && current_user.is_a?(Backend::User) && activity.trackable&.application - link_to app_name, backend_program_path(activity.trackable.application) + link_to app_name, developer_app_path(activity.trackable.application) else app_name end diff --git a/app/views/public_activity/program/_change.html.erb b/app/views/public_activity/program/_change.html.erb new file mode 100644 index 0000000..f140b97 --- /dev/null +++ b/app/views/public_activity/program/_change.html.erb @@ -0,0 +1,60 @@ +<% + changes = activity.parameters[:changes] || activity.parameters["changes"] + phrases = [] + + if changes.present? + changes.each do |field, diff| + field = field.to_s + diff = diff.symbolize_keys if diff.respond_to?(:symbolize_keys) + + case field + when "trust_level" + if diff[:to].to_s.downcase.gsub(" ", "_") == "hq_official" + phrases << "marked this app as HQ Official" + else + phrases << "changed trust level to #{ERB::Util.html_escape(diff[:to])}" + end + when "name" + phrases << "renamed app from #{ERB::Util.html_escape(diff[:from])} to #{ERB::Util.html_escape(diff[:to])}" + when "active" + if diff[:to] == true || diff[:to] == "true" + phrases << "reactivated this app" + else + phrases << "deactivated this app" + end + when "scopes_array" + added = Array(diff[:added]) + removed = Array(diff[:removed]) + if added.any? + phrases << "added scopes: #{added.map { |s| ERB::Util.html_escape(s) }.join(', ')}" + end + if removed.any? + phrases << "removed scopes: #{removed.map { |s| ERB::Util.html_escape(s) }.join(', ')}" + end + when "redirect_uris" + added = Array(diff[:added]) + removed = Array(diff[:removed]) + if added.any? + tooltip = added.map { |u| ERB::Util.html_escape(u) }.join("\n") + phrases << "added #{added.size} redirect #{added.size == 1 ? 'URI' : 'URIs'}" + end + if removed.any? + tooltip = removed.map { |u| ERB::Util.html_escape(u) }.join("\n") + phrases << "removed #{removed.size} redirect #{removed.size == 1 ? 'URI' : 'URIs'}" + end + when "onboarding_scenario" + if diff[:to].present? + phrases << "changed onboarding scenario to #{ERB::Util.html_escape(diff[:to])}" + else + phrases << "removed onboarding scenario" + end + end + end + end + + phrases << "updated app settings" if phrases.empty? +%> + +<%= render Components::PublicActivity::Snippet.new(activity, owner_component: Components::IdentityMention.new(activity.owner)) do %> + <%= safe_join(phrases.map(&:html_safe), "; ") %>. +<% end %> diff --git a/app/views/public_activity/program/_collaborator_accepted.html.erb b/app/views/public_activity/program/_collaborator_accepted.html.erb new file mode 100644 index 0000000..0207b06 --- /dev/null +++ b/app/views/public_activity/program/_collaborator_accepted.html.erb @@ -0,0 +1,3 @@ +<%= render Components::PublicActivity::Snippet.new(activity, owner_component: Components::IdentityMention.new(activity.owner)) do %> + accepted the collaboration invitation. +<% end %> diff --git a/app/views/public_activity/program/_collaborator_cancelled.html.erb b/app/views/public_activity/program/_collaborator_cancelled.html.erb new file mode 100644 index 0000000..ebe839d --- /dev/null +++ b/app/views/public_activity/program/_collaborator_cancelled.html.erb @@ -0,0 +1,7 @@ +<%= render Components::PublicActivity::Snippet.new(activity, owner_component: Components::IdentityMention.new(activity.owner)) do %> + <% if policy(activity.trackable).manage_collaborators? %> + cancelled invitation for <%= activity.parameters[:cancelled_email] || activity.parameters["cancelled_email"] %>. + <% else %> + cancelled an invitation. + <% end %> +<% end %> diff --git a/app/views/public_activity/program/_collaborator_declined.html.erb b/app/views/public_activity/program/_collaborator_declined.html.erb new file mode 100644 index 0000000..77fc4d0 --- /dev/null +++ b/app/views/public_activity/program/_collaborator_declined.html.erb @@ -0,0 +1,3 @@ +<%= render Components::PublicActivity::Snippet.new(activity, owner_component: Components::IdentityMention.new(activity.owner)) do %> + declined the collaboration invitation. +<% end %> diff --git a/app/views/public_activity/program/_collaborator_invited.html.erb b/app/views/public_activity/program/_collaborator_invited.html.erb new file mode 100644 index 0000000..3185bb4 --- /dev/null +++ b/app/views/public_activity/program/_collaborator_invited.html.erb @@ -0,0 +1,7 @@ +<%= render Components::PublicActivity::Snippet.new(activity, owner_component: Components::IdentityMention.new(activity.owner)) do %> + <% if policy(activity.trackable).manage_collaborators? %> + invited <%= activity.parameters[:invited_email] || activity.parameters["invited_email"] %> to collaborate. + <% else %> + invited a new collaborator. + <% end %> +<% end %> diff --git a/app/views/public_activity/program/_collaborator_removed.html.erb b/app/views/public_activity/program/_collaborator_removed.html.erb new file mode 100644 index 0000000..e3f5a28 --- /dev/null +++ b/app/views/public_activity/program/_collaborator_removed.html.erb @@ -0,0 +1,7 @@ +<%= render Components::PublicActivity::Snippet.new(activity, owner_component: Components::IdentityMention.new(activity.owner)) do %> + <% if policy(activity.trackable).manage_collaborators? %> + removed <%= activity.parameters[:removed_email] || activity.parameters["removed_email"] %>. + <% else %> + removed a collaborator. + <% end %> +<% end %> diff --git a/app/views/public_activity/program/_create.html.erb b/app/views/public_activity/program/_create.html.erb index 9d570c5..4b6aff4 100644 --- a/app/views/public_activity/program/_create.html.erb +++ b/app/views/public_activity/program/_create.html.erb @@ -1,3 +1,3 @@ -<%= render Components::PublicActivity::Snippet.new(activity, owner: activity.trackable&.owner_identity) do %> - created OAuth application "<%= activity.trackable&.name %>". +<%= render Components::PublicActivity::Snippet.new(activity, owner_component: Components::IdentityMention.new(activity.owner)) do %> + created this app. <% end %> diff --git a/app/views/public_activity/program/_destroy.html.erb b/app/views/public_activity/program/_destroy.html.erb index 57be427..2543cea 100644 --- a/app/views/public_activity/program/_destroy.html.erb +++ b/app/views/public_activity/program/_destroy.html.erb @@ -1,3 +1,3 @@ -<%= render Components::PublicActivity::Snippet.new(activity, owner: activity.trackable&.owner_identity) do %> - deleted OAuth application "<%= activity.parameters[:name] || 'Unknown' %>". +<%= render Components::PublicActivity::Snippet.new(activity, owner_component: Components::IdentityMention.new(activity.owner)) do %> + deleted this app. <% end %> diff --git a/app/views/public_activity/program/_revoke_all_authorizations.html.erb b/app/views/public_activity/program/_revoke_all_authorizations.html.erb new file mode 100644 index 0000000..c7926c6 --- /dev/null +++ b/app/views/public_activity/program/_revoke_all_authorizations.html.erb @@ -0,0 +1,3 @@ +<%= render Components::PublicActivity::Snippet.new(activity, owner_component: Components::IdentityMention.new(activity.owner)) do %> + revoked all authorizations (<%= activity.parameters[:count] || activity.parameters["count"] || 0 %> tokens). +<% end %> diff --git a/app/views/public_activity/program/_rotate_credentials.html.erb b/app/views/public_activity/program/_rotate_credentials.html.erb new file mode 100644 index 0000000..06fb478 --- /dev/null +++ b/app/views/public_activity/program/_rotate_credentials.html.erb @@ -0,0 +1,3 @@ +<%= render Components::PublicActivity::Snippet.new(activity, owner_component: Components::IdentityMention.new(activity.owner)) do %> + rotated credentials. +<% end %> diff --git a/app/views/public_activity/program/_update.html.erb b/app/views/public_activity/program/_update.html.erb index efc2ec0..fbe1290 100644 --- a/app/views/public_activity/program/_update.html.erb +++ b/app/views/public_activity/program/_update.html.erb @@ -1,3 +1,3 @@ -<%= render Components::PublicActivity::Snippet.new(activity, owner: activity.trackable&.owner_identity) do %> - updated OAuth application "<%= activity.trackable&.name %>". +<%= render Components::PublicActivity::Snippet.new(activity, owner_component: Components::IdentityMention.new(activity.owner)) do %> + updated app settings. <% end %> diff --git a/app/views/public_activity/verification/_reject.html.erb b/app/views/public_activity/verification/_reject.html.erb index d3250ee..8c768a0 100644 --- a/app/views/public_activity/verification/_reject.html.erb +++ b/app/views/public_activity/verification/_reject.html.erb @@ -14,5 +14,5 @@ reason_name = Verification::DocumentVerification::REJECTION_REASON_NAMES[reason] || reason %> for <%= reason_name.downcase %><% end - %>. + %>. <% end %> diff --git a/config/locales/en.yml b/config/locales/en.yml index aeb1575..b957169 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -151,17 +151,26 @@ 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 + invitation_banner: "You've been invited to collaborate on %{app_name}." + accept_invitation: Accept + decline_invitation: Decline + 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 +178,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 +195,50 @@ 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: prgmK + 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 + click_to_copy_redirect_uri: click to copy redirect URI + 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? + pending_badge: Pending + cancel_invitation: Cancel Invitation + pending_invitations_heading: Pending Invitations + activity_log: Activity Log + show_activity: Load Activity Log + no_activity: No activity recorded yet. 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 +248,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: @@ -225,11 +273,28 @@ en: destroy: 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. + success: OAuth app credentials rotated successfully. + revoke_all_authorizations: + success: + one: "Revoked 1 token." + other: "Revoked %{count} tokens." set_app: not_found: OAuth app not found. + developer_app_collaborators: + create: + invited: "Invitation sent!" + already_invited: "This person has already been invited." + cannot_add_self: "That's you, you goof!" + invalid_email: "Please enter a valid email address." + destroy: + success: Collaborator removed. + developer_app_collaborator_invitations: + accept: + success: "You are now a collaborator on this app." + decline: + success: "Invitation declined." + cancel: + success: "Invitation cancelled." addresses: first_name: First name last_name: Last name diff --git a/config/routes.rb b/config/routes.rb index 9672560..8f05ba3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -226,11 +226,7 @@ Rails.application.routes.draw do 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,6 +360,17 @@ Rails.application.routes.draw do resources :developer_apps, path: "developer/apps" do member do post :rotate_credentials + post :revoke_all_authorizations + get :activity_log + end + resources :collaborators, only: [ :create, :destroy ], + controller: "developer_app_collaborators" + resources :collaborator_invites, only: [], controller: "developer_app_collaborator_invitations" do + member do + post :accept + post :decline + post :cancel + end end 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 0000000..9967982 --- /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 0000000..e8d485c --- /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/migrate/20260302000001_add_status_to_program_collaborators.rb b/db/migrate/20260302000001_add_status_to_program_collaborators.rb new file mode 100644 index 0000000..08de67d --- /dev/null +++ b/db/migrate/20260302000001_add_status_to_program_collaborators.rb @@ -0,0 +1,14 @@ +class AddStatusToProgramCollaborators < ActiveRecord::Migration[8.0] + def change + add_column :program_collaborators, :status, :string, default: "pending", null: false + add_column :program_collaborators, :accepted_at, :datetime + add_column :program_collaborators, :invited_email, :string + + # Backfill existing rows as accepted + reversible do |dir| + dir.up do + ProgramCollaborator.update_all(status: "accepted", accepted_at: Time.current) + end + end + end +end diff --git a/db/migrate/20260302000002_allow_null_identity_on_program_collaborators.rb b/db/migrate/20260302000002_allow_null_identity_on_program_collaborators.rb new file mode 100644 index 0000000..4d1b807 --- /dev/null +++ b/db/migrate/20260302000002_allow_null_identity_on_program_collaborators.rb @@ -0,0 +1,8 @@ +class AllowNullIdentityOnProgramCollaborators < ActiveRecord::Migration[8.0] + def change + change_column_null :program_collaborators, :identity_id, true + add_index :program_collaborators, [ :program_id, :invited_email ], unique: true, + where: "status IN ('pending', 'accepted')", + name: "idx_program_collabs_on_program_email_visible" + end +end diff --git a/db/schema.rb b/db/schema.rb index 87fdef8..25bf43c 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_03_02_000002) 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 @@ ActiveRecord::Schema[8.0].define(version: 2026_02_18_200000) 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)" 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 @@ ActiveRecord::Schema[8.0].define(version: 2026_02_18_200000) 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 @@ -526,6 +527,20 @@ ActiveRecord::Schema[8.0].define(version: 2026_02_18_200000) do 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" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "status", default: "pending", null: false + t.datetime "accepted_at" + t.string "invited_email" + 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", "invited_email"], name: "idx_program_collabs_on_program_email_visible", unique: true, where: "((status)::text = ANY ((ARRAY['pending'::character varying, 'accepted'::character varying])::text[]))" + 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 +602,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_02_18_200000) 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" @@ -628,8 +631,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_02_18_200000) do 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 0000000..ed9d2ce --- /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 c530898..192a338 100644 --- a/spec/factories/identities.rb +++ b/spec/factories/identities.rb @@ -17,5 +17,13 @@ FactoryBot.define do 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 0000000..0906660 --- /dev/null +++ b/spec/factories/program_collaborators.rb @@ -0,0 +1,12 @@ +FactoryBot.define do + factory :program_collaborator do + association :program + association :identity + invited_email { identity.primary_email } + + trait :accepted do + status { "accepted" } + accepted_at { Time.current } + end + end +end