mirror of
https://github.com/System-End/identity-vault.git
synced 2026-04-19 16:28:21 +00:00
epic: overhaul program management experience (#188)
* temp commit * lemme do it * nope * let them do it too * collab invite model * better visuals on progman * waow * danger will robinson * show apps on backend & link user * first pass on app auditability! * no lastnaming admins * async frame that shit! * waugh * can't add yourself * fix reinvite * sidebar badging * lint... * gotta be on the app! * let that get rescued by applcon * already in revoke_all_authorizations * woag * the routes you grew up with no longer exist * what would the UI for that even be? * sorch * much better! * frickin validations
This commit is contained in:
parent
77152b525d
commit
9998147a4e
65 changed files with 1876 additions and 599 deletions
23
app/components/bootleg_turbo_button.rb
Normal file
23
app/components/bootleg_turbo_button.rb
Normal file
|
|
@ -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
|
||||
37
app/components/identity_mention.rb
Normal file
37
app/components/identity_mention.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
24
app/controllers/concerns/identity_authorizable.rb
Normal file
24
app/controllers/concerns/identity_authorizable.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
63
app/controllers/developer_app_collaborators_controller.rb
Normal file
63
app/controllers/developer_app_collaborators_controller.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
55
app/frontend/js/scope-editor.js
Normal file
55
app/frontend/js/scope-editor.js
Normal file
|
|
@ -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));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
357
app/frontend/stylesheets/snippets/developer_apps.scss
Normal file
357
app/frontend/stylesheets/snippets/developer_apps.scss
Normal file
|
|
@ -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 <a> 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; } }
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
58
app/models/concerns/auditable.rb
Normal file
58
app/models/concerns/auditable.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
33
app/models/program_collaborator.rb
Normal file
33
app/models/program_collaborator.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -70,6 +70,11 @@
|
|||
<%= f.check_box :permabanned %>
|
||||
<small>permanently ban (makes ineligible)</small>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<%= f.label :can_hq_officialize, "can officialize apps" %>
|
||||
<%= f.check_box :can_hq_officialize %>
|
||||
<small>allow this identity to promote apps to hq_official</small>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@
|
|||
<tbody>
|
||||
<% @all_programs.each do |program| %>
|
||||
<tr>
|
||||
<td><%= link_to program.name, backend_program_path(program) %></td>
|
||||
<td><%= link_to program.name, developer_app_path(program) %></td>
|
||||
<td><code><%= program.scopes.presence || "—" %></code></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
|
|
@ -156,6 +156,39 @@
|
|||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-header"><h3>developed apps</h3></div>
|
||||
<div class="section-content">
|
||||
<% if @owned_apps.any? || @collaborated_apps.any? %>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>app</th>
|
||||
<th>role</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @owned_apps.each do |app| %>
|
||||
<tr>
|
||||
<td><%= link_to app.name, developer_app_path(app) %> <span class="badge <%= app.hq_official? ? 'success' : '' %>"><%= app.trust_level.to_s.titleize %></span></td>
|
||||
<td>owner</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
<% @collaborated_apps.each do |app| %>
|
||||
<tr>
|
||||
<td><%= link_to app.name, developer_app_path(app) %> <span class="badge <%= app.hq_official? ? 'success' : '' %>"><%= app.trust_level.to_s.titleize %></span></td>
|
||||
<td>collaborator</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="empty-state">no developer apps</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-header"><h3>audit log</h3></div>
|
||||
<div class="section-content">
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
<span>
|
||||
<%= inline_icon("briefcase", size: 16) %>
|
||||
<%= link_to backend_program_path(program), class: "identity-link", target: "_blank" do %>
|
||||
<%= program.name %>
|
||||
<% end %>
|
||||
</span>
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
<div class="grid">
|
||||
<%= render Components::Backend::Card.new(title: "edit: #{@program.name}") do %>
|
||||
<%= render Components::Backend::Item.new(icon: "⭠", href: backend_program_path(@program)) do %>
|
||||
<b>cancel</b>
|
||||
<% end %>
|
||||
<hr>
|
||||
<%= render Backend::Programs::Form.new @program %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
<div class="grid">
|
||||
<%= render Components::Backend::Card.new(title: "oauth programs") do %>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>name</th>
|
||||
<th>owner</th>
|
||||
<th>scopes</th>
|
||||
<th>users</th>
|
||||
<th>active</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @programs.each do |program| %>
|
||||
<tr>
|
||||
<td>
|
||||
<%= link_to backend_program_path(program) do %>
|
||||
<b><%= program.name %></b>
|
||||
<% end %>
|
||||
<% if program.description.present? %>
|
||||
<br><small><%= truncate(program.description, length: 40) %></small>
|
||||
<% end %>
|
||||
</td>
|
||||
<td>
|
||||
<% if program.owner_identity.present? %>
|
||||
<%= render Components::UserMention.new(program.owner_identity) %>
|
||||
<% else %>
|
||||
<i>HQ</i>
|
||||
<% end %>
|
||||
</td>
|
||||
<td><code><%= program.scopes.presence || "—" %></code></td>
|
||||
<td><%= program.identities.distinct.count %></td>
|
||||
<td><%= render_checkbox(program.active?) %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<hr>
|
||||
<%= render Components::Backend::Item.new(icon: "+", href: new_backend_program_path) do %>
|
||||
<b>new program</b>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
<div class="grid">
|
||||
<%= render Components::Backend::Card.new(title: "new program") do %>
|
||||
<%= render Components::Backend::Item.new(icon: "⭠", href: backend_programs_path) do %>
|
||||
<b>cancel</b>
|
||||
<% end %>
|
||||
<hr>
|
||||
<%= render Backend::Programs::Form.new @program %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
<div class="grid">
|
||||
<%= render Components::Backend::Card.new(title: "program: #{@program.name}") do %>
|
||||
<%= render Components::Backend::Item.new(icon: "⭠", href: backend_programs_path) do %>
|
||||
<b>back to programs</b>
|
||||
<% end %>
|
||||
<hr>
|
||||
<div class="section">
|
||||
<div class="section-header"><h3>details</h3></div>
|
||||
<div class="section-content">
|
||||
<% if @program.description.present? %>
|
||||
<p><%= @program.description %></p>
|
||||
<% end %>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">owner</span>
|
||||
<span class="detail-value">
|
||||
<% if @program.owner_identity.present? %>
|
||||
<%= render Components::UserMention.new(@program.owner_identity) %>
|
||||
<% else %>
|
||||
<i>HQ</i>
|
||||
<% end %>
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">status</span>
|
||||
<span class="detail-value"><span class="badge <%= @program.active? ? 'success' : 'danger' %>"><%= @program.active? ? "active" : "inactive" %></span></span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">trust</span>
|
||||
<span class="detail-value"><%= @program.trust_level.to_s.titleize %></span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">scopes</span>
|
||||
<span class="detail-value"><code><%= @program.scopes.presence || "none" %></code></span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">users</span>
|
||||
<span class="detail-value"><%= @identities_count %></span>
|
||||
</div>
|
||||
<% if @program.onboarding_scenario.present? %>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">onboarding</span>
|
||||
<span class="detail-value"><%= @program.onboarding_scenario.titleize %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="section">
|
||||
<div class="section-header"><h3>credentials</h3></div>
|
||||
<div class="section-content lowered">
|
||||
<div class="form-row">
|
||||
<label>client id</label>
|
||||
<input type="text" value="<%= @program.uid %>" readonly onclick="this.select()" data-click-to-copy autocomplete="off">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>secret</label>
|
||||
<input type="text" value="<%= @program.secret %>" readonly onclick="this.select()" data-click-to-copy autocomplete="off">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>api key</label>
|
||||
<input type="text" value="<%= @program.program_key %>" readonly onclick="this.select()" data-click-to-copy autocomplete="off">
|
||||
</div>
|
||||
<% if policy(@program).rotate_credentials? %>
|
||||
<div class="form-row" style="margin-top: 0.5rem;">
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% if @program.redirect_uri.present? %>
|
||||
<div class="section">
|
||||
<div class="section-header"><h3>redirect uris</h3></div>
|
||||
<div class="section-content">
|
||||
<% @program.redirect_uri.split.each do |uri| %>
|
||||
<div class="detail-row">
|
||||
<span class="detail-value"><code><%= uri %></code></span>
|
||||
<%= link_to "auth →", oauth_authorization_path(client_id: @program.uid, redirect_uri: uri, response_type: 'code', scope: @program.scopes), target: '_blank' %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<hr>
|
||||
<%= render Components::Backend::Item.new(icon: "✎", href: edit_backend_program_path(@program)) do %>
|
||||
<b>edit program</b>
|
||||
<% end %>
|
||||
<%= render Components::Backend::Item.new(icon: "⭢", href: oauth_application_path(@program), target: "_blank") do %>
|
||||
<b>oauth app</b>
|
||||
<% end %>
|
||||
<%= link_to backend_program_path(@program), method: :delete, data: { confirm: "delete this program and all associated data?" }, class: "item" do %>
|
||||
<figure class="icon">✕</figure>
|
||||
<span class="text"><b>delete program</b></span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
@ -32,7 +32,7 @@
|
|||
<% if current_user&.program_manager? || current_user&.super_admin? %>
|
||||
<b>Program manager:</b>
|
||||
<div data-navigable-item>
|
||||
<%= render Components::Backend::Item.new(icon: "⭢", href: backend_programs_path) do %>
|
||||
<%= render Components::Backend::Item.new(icon: "⭢", href: developer_apps_path) do %>
|
||||
<b>Manage OAuth2 apps</b>
|
||||
<p class="shortcode"> [ APPS ]</p>
|
||||
<% end %>
|
||||
|
|
|
|||
5
app/views/developer_apps/activity_log.html.erb
Normal file
5
app/views/developer_apps/activity_log.html.erb
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<% if @activities.any? %>
|
||||
<%= render Components::PublicActivity::Container.new(@activities) %>
|
||||
<% else %>
|
||||
<p style="color: var(--pico-muted-color);"><%= t("developer_apps.show.no_activity") %></p>
|
||||
<% end %>
|
||||
|
|
@ -3,6 +3,15 @@
|
|||
<%= link_to t(".back_to_app"), developer_app_path(@app) %>
|
||||
</div>
|
||||
|
||||
<%# 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 %>
|
||||
|
||||
<div class="field-group">
|
||||
<%= f.label :name, t(".app_name") %>
|
||||
<%= f.text_field :name, required: true %>
|
||||
</div>
|
||||
<div class="page-sections">
|
||||
<%# === Card 1: App Details === %>
|
||||
<section class="section-card">
|
||||
<h3><%= t(".app_details_heading", default: "App Details") %></h3>
|
||||
<div class="field-group">
|
||||
<%= f.label :name, t(".app_name") %>
|
||||
<%= f.text_field :name, required: true %>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<%= f.label :redirect_uri, t(".redirect_uri") %>
|
||||
<%= f.text_area :redirect_uri, rows: 3, required: true %>
|
||||
<small class="usn"><%= t ".redirect_uri_hint" %></small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="field-group">
|
||||
<%= f.label :redirect_uri, t(".redirect_uri") %>
|
||||
<%= f.text_area :redirect_uri, rows: 3, required: true %>
|
||||
<small class="usn"><%= t ".redirect_uri_hint" %></small>
|
||||
</div>
|
||||
<%# === Cards 2+3 wrapped in Alpine === %>
|
||||
<div x-data="scopeEditor(<%= editor_data.to_json %>)">
|
||||
|
||||
<div class="field-group">
|
||||
<%= f.label :scopes_array, t(".scopes") %>
|
||||
<input type="hidden" name="program[scopes_array][]" value="">
|
||||
<% Program::COMMUNITY_ALLOWED_SCOPES.each do |scope| %>
|
||||
<label class="checkbox-label">
|
||||
<%= check_box_tag "program[scopes_array][]", scope, @app.has_scope?(scope), id: "program_scopes_#{scope}" %>
|
||||
<span><%= scope %></span>
|
||||
<small style="color: var(--text-secondary);">
|
||||
<%= Program::AVAILABLE_SCOPES.find { |s| s[:name] == scope }&.dig(:description) %>
|
||||
</small>
|
||||
</label>
|
||||
<%# === Card 2: Trust Level (if user can edit it) === %>
|
||||
<% if policy(@app).update_trust_level? %>
|
||||
<section class="section-card">
|
||||
<h3><%= t(".trust_level_heading", default: "Trust Level") %></h3>
|
||||
<div class="field-group">
|
||||
<%= 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()" } %>
|
||||
<small class="usn"><%= t(".trust_level_hint", default: "Controls which scopes the app may request and how the consent screen appears to users.") %></small>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<%# === Card 3: OAuth Scopes === %>
|
||||
<section class="section-card">
|
||||
<h3><%= t(".oauth_scopes_heading", default: "OAuth Scopes") %></h3>
|
||||
|
||||
<%# Warning when trust level downgrade stripped scopes %>
|
||||
<div x-show="removedScopes.length > 0" x-cloak class="scope-strip-warning">
|
||||
<strong><%= t(".scopes_removed", default: "Scopes removed:") %></strong>
|
||||
<span x-text="removedScopes.join(', ')"></span>
|
||||
— <%= t(".scopes_not_valid_for_trust_level", default: "not valid for this trust level.") %>
|
||||
<button type="button" @click="dismissWarning()">✕</button>
|
||||
</div>
|
||||
|
||||
<%# Blank input ensures empty array when nothing checked %>
|
||||
<input type="hidden" name="program[scopes_array][]" value="">
|
||||
|
||||
<%# Editable scope checkboxes (Alpine-rendered) %>
|
||||
<template x-for="scope in editableScopes" :key="scope.name">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="program[scopes_array][]"
|
||||
:value="scope.name"
|
||||
:checked="isChecked(scope.name)"
|
||||
@change="toggle(scope.name)">
|
||||
<div>
|
||||
<span x-text="scope.name"></span>
|
||||
<small x-text="scope.description"></small>
|
||||
</div>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<%# Locked scopes — hidden inputs to preserve, shown as disabled rows %>
|
||||
<template x-if="lockedScopes.length > 0">
|
||||
<div>
|
||||
<p class="locked-scopes-label"><%= t(".scopes_locked_by_higher_permission", default: "Managed by a higher-permission user:") %></p>
|
||||
<template x-for="scope in lockedScopes" :key="scope.name">
|
||||
<div>
|
||||
<input type="hidden" name="program[scopes_array][]" :value="scope.name">
|
||||
<label class="checkbox-label locked">
|
||||
<input type="checkbox" disabled checked>
|
||||
<div>
|
||||
<span x-text="scope.name"></span>
|
||||
<small x-text="scope.description"></small>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<%# 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 %>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<%# === Card 4: Admin Settings (if applicable) === %>
|
||||
<% if policy(@app).update_onboarding_scenario? || policy(@app).update_active? %>
|
||||
<section class="section-card">
|
||||
<h3><%= t(".admin_settings_heading", default: "Admin Settings") %></h3>
|
||||
<% if policy(@app).update_onboarding_scenario? %>
|
||||
<div class="field-group">
|
||||
<%= 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" %>
|
||||
<small class="usn"><%= t ".onboarding_scenario_hint" %></small>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if policy(@app).update_active? %>
|
||||
<div class="field-group">
|
||||
<label class="checkbox-label">
|
||||
<%= f.check_box :active %>
|
||||
<span><%= t(".active") %></span>
|
||||
</label>
|
||||
</div>
|
||||
<% end %>
|
||||
</section>
|
||||
<% end %>
|
||||
<small class="usn"><%= t ".scopes_hint", scopes: Program::COMMUNITY_ALLOWED_SCOPES.join(", ") %></small>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.75rem;">
|
||||
<div class="form-actions">
|
||||
<%= f.submit t(".update"), class: "button" %>
|
||||
<%= link_to t(".cancel"), developer_app_path(@app), class: "button-secondary" %>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,46 +1,67 @@
|
|||
<div class="page-header">
|
||||
<h1><%= t ".title" %></h1>
|
||||
<h1><%= admin? ? t(".title_admin") : t(".title") %></h1>
|
||||
<i>How?..Just <%= link_to "Watch The Free Video", doc_path("tldr"), target: "_blank" %> > </i>
|
||||
<div class="header-actions">
|
||||
<%= link_to t(".create_new"), new_developer_app_path, role: "button" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if @pending_invitations.any? %>
|
||||
<% @pending_invitations.each do |invitation| %>
|
||||
<%= render Components::Banner.new(kind: :info) do %>
|
||||
<span><%= t(".invitation_banner", app_name: invitation.program.name) %></span>
|
||||
<div class="banner-actions">
|
||||
<%= button_to t(".accept_invitation"),
|
||||
accept_developer_app_collaborator_invite_path(invitation.program, invitation),
|
||||
method: :post, class: "small-btn approve" %>
|
||||
<%= button_to t(".decline_invitation"),
|
||||
decline_developer_app_collaborator_invite_path(invitation.program, invitation),
|
||||
method: :post, class: "small-btn secondary" %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<div class="page-sections">
|
||||
<% if admin? %>
|
||||
<div class="search-bar">
|
||||
<%= form_with url: developer_apps_path, method: :get, local: true do %>
|
||||
<%= text_field_tag :search, params[:search], placeholder: t(".search_placeholder"), type: "search" %>
|
||||
<button type="submit"><%= t(".search_button") %></button>
|
||||
<% if params[:search].present? %>
|
||||
<%= link_to "✕ #{t('.clear_filters')}", developer_apps_path, class: "button-secondary small-btn" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<span class="search-results-count"><%= t(".results_count", count: @apps.total_count) %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @apps.any? %>
|
||||
<div class="header-actions">
|
||||
<%= link_to t(".create_new"), new_developer_app_path, role: "button" %>
|
||||
<div class="app-list">
|
||||
<% @apps.each do |app| %>
|
||||
<section class="card app-list-item">
|
||||
<div class="app-list-item-identity">
|
||||
<div>
|
||||
<h3><%= link_to app.name, developer_app_path(app) %></h3>
|
||||
<small><%= app.scopes_array.join(", ").presence || t(".no_scopes") %></small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-list-item-meta">
|
||||
<% if admin? && app.owner_identity %>
|
||||
<small class="usn"><%= app.owner_identity.full_name %></small>
|
||||
<% end %>
|
||||
<code class="client-id"><%= app.uid %></code>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= paginate @apps %>
|
||||
<% else %>
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon"><%= inline_icon("code", size: 48) %></div>
|
||||
<h3><%= t ".blank_slate.title" %></h3>
|
||||
<p><%= t ".blank_slate.cta" %></p>
|
||||
<%= link_to t(".blank_slate.create"), new_developer_app_path, role: "button" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @apps.any? %>
|
||||
<% @apps.each do |app| %>
|
||||
<section class="section-card" style="margin-bottom: 1rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
|
||||
<div>
|
||||
<h3 style="margin: 0 0 0.5rem 0;"><%= link_to app.name, developer_app_path(app) %></h3>
|
||||
<div style="font-size: 0.9rem; color: var(--text-secondary);">
|
||||
<%= app.scopes_array.join(", ").presence || t(".no_scopes") %> ·
|
||||
<%= app.trust_level.to_s.titleize %>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<%= 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") } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; gap: 0.75rem; margin-top: 1rem; font-size: 0.9rem;">
|
||||
<div>
|
||||
<strong><%= t ".client_id" %>:</strong>
|
||||
<%= copy_to_clipboard app.uid, label: t(".click_to_copy_client_id") do %>
|
||||
<code style="font-size: 0.85rem; margin-left: 0.5rem; cursor: pointer;"><%= app.uid %></code>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon"><%= inline_icon("code", size: 48) %></div>
|
||||
<h3><%= t ".blank_slate.title" %></h3>
|
||||
<p><%= t ".blank_slate.cta" %></p>
|
||||
<%= link_to t(".blank_slate.create"), new_developer_app_path, role: "button" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,19 @@
|
|||
<%= link_to t(".back_to_apps"), developer_apps_path %>
|
||||
</div>
|
||||
|
||||
<%# 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 %>
|
||||
|
||||
<div class="field-group">
|
||||
<%= f.label :name, t(".app_name") %>
|
||||
<%= f.text_field :name, placeholder: t(".app_name_placeholder"), required: true %>
|
||||
<div class="page-sections">
|
||||
<%# === Card 1: App Details === %>
|
||||
<section class="section-card">
|
||||
<h3><%= t(".app_details_heading", default: "App Details") %></h3>
|
||||
<div class="field-group">
|
||||
<%= f.label :name, t(".app_name") %>
|
||||
<%= f.text_field :name, placeholder: t(".app_name_placeholder"), required: true %>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<%= f.label :redirect_uri, t(".redirect_uri") %>
|
||||
<%= f.text_area :redirect_uri, placeholder: t(".redirect_uri_placeholder"), rows: 3, required: true %>
|
||||
<small class="usn"><%= t ".redirect_uri_hint" %></small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%# === Cards 2+3 wrapped in Alpine === %>
|
||||
<div x-data="scopeEditor(<%= editor_data.to_json %>)">
|
||||
|
||||
<%# === Card 2: Trust Level (if user can set it) === %>
|
||||
<% if policy(@app).update_trust_level? %>
|
||||
<section class="section-card">
|
||||
<h3><%= t(".trust_level_heading", default: "Trust Level") %></h3>
|
||||
<div class="field-group">
|
||||
<%= 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()" } %>
|
||||
<small class="usn"><%= t(".trust_level_hint", default: "Controls which scopes the app may request and how the consent screen appears to users.") %></small>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<%# === Card 3: OAuth Scopes === %>
|
||||
<section class="section-card">
|
||||
<h3><%= t(".oauth_scopes_heading", default: "OAuth Scopes") %></h3>
|
||||
|
||||
<%# Warning when trust level downgrade stripped scopes %>
|
||||
<div x-show="removedScopes.length > 0" x-cloak class="scope-strip-warning">
|
||||
<strong><%= t("developer_apps.edit.scopes_removed", default: "Scopes removed:") %></strong>
|
||||
<span x-text="removedScopes.join(', ')"></span>
|
||||
— <%= t("developer_apps.edit.scopes_not_valid_for_trust_level", default: "not valid for this trust level.") %>
|
||||
<button type="button" @click="dismissWarning()">✕</button>
|
||||
</div>
|
||||
|
||||
<%# Quick-fill for YSWS programs (HQ officializers only) %>
|
||||
<template x-if="showYswsDefaults">
|
||||
<button type="button" class="button-secondary small-btn ysws-defaults-btn" @click="applyYswsDefaults()"><%= t(".ysws_defaults", default: "Default YSWS scopes") %></button>
|
||||
</template>
|
||||
|
||||
<%# Blank input ensures empty array when nothing checked %>
|
||||
<input type="hidden" name="program[scopes_array][]" value="">
|
||||
|
||||
<%# Editable scope checkboxes (Alpine-rendered) %>
|
||||
<template x-for="scope in editableScopes" :key="scope.name">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="program[scopes_array][]"
|
||||
:value="scope.name"
|
||||
:checked="isChecked(scope.name)"
|
||||
@change="toggle(scope.name)">
|
||||
<div>
|
||||
<span x-text="scope.name"></span>
|
||||
<small x-text="scope.description"></small>
|
||||
</div>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<%# 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 %>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<%= f.label :redirect_uri, t(".redirect_uri") %>
|
||||
<%= f.text_area :redirect_uri, placeholder: t(".redirect_uri_placeholder"), rows: 3, required: true %>
|
||||
<small class="usn"><%= t ".redirect_uri_hint" %></small>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<%= f.label :scopes_array, t(".scopes") %>
|
||||
<input type="hidden" name="program[scopes_array][]" value="">
|
||||
<% Program::COMMUNITY_ALLOWED_SCOPES.each do |scope| %>
|
||||
<label class="checkbox-label">
|
||||
<%= check_box_tag "program[scopes_array][]", scope, false, id: "program_scopes_#{scope}" %>
|
||||
<span><%= scope %></span>
|
||||
<small style="color: var(--text-secondary);">
|
||||
<%= Program::AVAILABLE_SCOPES.find { |s| s[:name] == scope }&.dig(:description) %>
|
||||
</small>
|
||||
</label>
|
||||
<% end %>
|
||||
<small class="usn"><%= t ".scopes_hint", scopes: Program::COMMUNITY_ALLOWED_SCOPES.join(", ") %></small>
|
||||
</div>
|
||||
<br>
|
||||
<br>
|
||||
<%= render Components::Banner.new(kind: :info) do %>
|
||||
<%= t(".trust_level_note_html").html_safe %>
|
||||
<% end %>
|
||||
|
||||
<div style="display: flex; gap: 0.75rem;">
|
||||
<div class="form-actions">
|
||||
<%= f.submit t(".create"), class: "button" %>
|
||||
<%= link_to t(".cancel"), developer_apps_path, class: "button-secondary" %>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,73 +2,194 @@
|
|||
<h1><%= @app.name %></h1>
|
||||
<%= link_to t(".back_to_apps"), developer_apps_path %>
|
||||
</div>
|
||||
<%= render Components::Banner.new(kind: :warning) do %>
|
||||
<strong><%= t ".security_warning_title" %></strong><br>
|
||||
<%= t ".security_warning_message" %>
|
||||
<% end %>
|
||||
<section class="section-card" style="margin-bottom: 1.5rem;">
|
||||
<h3><%= t ".oauth_credentials" %></h3>
|
||||
|
||||
<div style="display: grid; gap: 1rem; margin-top: 1rem;">
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.5rem; font-size: 0.9rem;"><%= t ".client_id" %></label>
|
||||
<%= 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 %>
|
||||
</div>
|
||||
<div class="dev-panel">
|
||||
<aside class="dev-panel-sidebar">
|
||||
<%# App identity card %>
|
||||
<section class="section-card" style="text-align: center;">
|
||||
<div class="app-icon large"><%= @app.name[0].upcase %></div>
|
||||
<h2 class="app-identity-name"><%= @app.name %></h2>
|
||||
<div class="app-badge-row">
|
||||
<span class="badge <%= @app.active? ? 'success' : 'danger' %>"><%= @app.active? ? t(".active") : t(".inactive") %></span>
|
||||
<span class="badge <%= @app.hq_official? ? 'success' : '' %>"><%= @app.trust_level.to_s.titleize %></span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.5rem; font-size: 0.9rem;"><%= t ".client_secret" %></label>
|
||||
<%= copy_to_clipboard @app.secret, label: t(".click_to_copy_client_secret") do %>
|
||||
<%= text_field_tag :client_secret, @app.secret, readonly: true, style: "font-family: monospace; font-size: 0.9rem; cursor: pointer;", autocomplete: "off" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.5rem; font-size: 0.9rem;"><%= t ".redirect_uris" %></label>
|
||||
<% @app.redirect_uri.split.each do |uri| %>
|
||||
<div style="margin-bottom: 0.5rem; display: grid; grid-template-columns: 1fr auto; gap: 0.5rem; align-items: center;">
|
||||
<%= text_field_tag "redirect_uri_#{uri.hash}", uri, readonly: true, style: "font-family: monospace; font-size: 0.9rem;", autocomplete: "off" %>
|
||||
<%= link_to oauth_authorization_path(client_id: @app.uid, redirect_uri: uri, response_type: 'code', scope: @app.scopes),
|
||||
class: "button-secondary", style: "font-size: 0.85rem; padding: 0.5rem 0.75rem; white-space: nowrap; display: inline-flex; align-items: center; gap: 0.4rem;", target: "_blank" do %>
|
||||
<%= t(".test_auth") %> <%= inline_icon("external", size: 14) %>
|
||||
<% end %>
|
||||
<% if admin? && @app.owner_identity %>
|
||||
<div class="app-meta-row">
|
||||
<span class="meta-label"><%= t(".owner") %></span>
|
||||
<span class="meta-value"><%= link_to @app.owner_identity.full_name, backend_identity_path(@app.owner_identity) %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
<small class="usn" style="display: block; margin-top: 0.25rem;"><%= t ".auth_link_hint" %></small>
|
||||
</div>
|
||||
<div>
|
||||
<%= button_to t(".rotate_credentials"), rotate_credentials_developer_app_path(@app), method: :post, class: "danger small-btn",
|
||||
form: { "hx-confirm": t(".rotate_confirm") } %>
|
||||
<small class="usn" style="display: block; margin-top: 0.25rem;"><%= t ".rotate_hint" %></small>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="app-meta-row">
|
||||
<span class="meta-label"><%= t(".user_count") %></span>
|
||||
<span class="meta-value"><%= @identities_count %></span>
|
||||
</div>
|
||||
<div class="app-meta-row">
|
||||
<span class="meta-label"><%= t(".scopes") %></span>
|
||||
<span class="meta-value">
|
||||
<% if @app.scopes_array.any? %>
|
||||
<code><%= @app.scopes_array.join(", ") %></code>
|
||||
<% else %>
|
||||
<span style="color: var(--pico-muted-color);"><%= t(".no_scopes") %></span>
|
||||
<% end %>
|
||||
</span>
|
||||
</div>
|
||||
<% if @app.onboarding_scenario.present? %>
|
||||
<div class="app-meta-row">
|
||||
<span class="meta-label"><%= t(".onboarding_scenario") %></span>
|
||||
<span class="meta-value"><%= @app.onboarding_scenario.titleize %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
<section class="section-card" style="margin-bottom: 1.5rem;">
|
||||
<h3><%= t ".configuration" %></h3>
|
||||
<%# Quick actions %>
|
||||
<%= link_to t(".edit_app"), edit_developer_app_path(@app), class: "button-secondary" %>
|
||||
|
||||
<div style="display: grid; gap: 1rem; margin-top: 1rem;">
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.5rem; font-size: 0.9rem;"><%= t ".scopes" %></label>
|
||||
<div>
|
||||
<% if @app.scopes_array.any? %>
|
||||
<%= text_field_tag :scopes, @app.scopes_array.join(", "), readonly: true, autocomplete: "off" %>
|
||||
<% else %>
|
||||
<span style="color: var(--text-secondary); font-size: 0.9rem;"><%= t ".no_scopes" %></span>
|
||||
<%# Danger zone %>
|
||||
<% if policy(@app).rotate_credentials? || policy(@app).destroy? %>
|
||||
<section class="card danger-card">
|
||||
<h4><%= t(".danger_zone") %></h4>
|
||||
<% 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 %>
|
||||
</section>
|
||||
<% end %>
|
||||
</aside>
|
||||
|
||||
<div class="dev-panel-main">
|
||||
<%# Security warning %>
|
||||
<%= render Components::Banner.new(kind: :warning) do %>
|
||||
<strong><%= t ".security_warning_title" %></strong><br>
|
||||
<%= t ".security_warning_message" %>
|
||||
<% end %>
|
||||
|
||||
<%# OAuth Credentials %>
|
||||
<section class="section-card">
|
||||
<h3><%= t ".oauth_credentials" %></h3>
|
||||
<div class="credential-block">
|
||||
<label><%= t ".client_id" %></label>
|
||||
<%= copy_to_clipboard @app.uid, label: t(".click_to_copy_client_id") do %>
|
||||
<code class="credential-value"><%= @app.uid %></code>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% if policy(@app).view_secret? %>
|
||||
<div class="credential-block" x-data="{ revealed: false }">
|
||||
<label><%= t ".client_secret" %></label>
|
||||
<div class="cred-reveal-row">
|
||||
<%= copy_to_clipboard @app.secret, label: t(".click_to_copy_client_secret") do %>
|
||||
<code class="credential-value" x-show="revealed"><%= @app.secret %></code>
|
||||
<span class="credential-value credential-masked" x-show="!revealed">[hidden]</span>
|
||||
<% end %>
|
||||
<button type="button" class="button-secondary small-btn usn" @click="revealed = !revealed" x-text="revealed ? '<%= t('.hide') %>' : '<%= t('.reveal') %>'"></button>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if policy(@app).view_api_key? %>
|
||||
<div class="credential-block super-admin-tool" x-data="{ revealed: false }">
|
||||
<label><%= t ".api_key" %></label>
|
||||
<div class="cred-reveal-row">
|
||||
<%= copy_to_clipboard @app.program_key, label: t(".click_to_copy_api_key") do %>
|
||||
<code class="credential-value" x-show="revealed"><%= @app.program_key %></code>
|
||||
<span class="credential-value credential-masked" x-show="!revealed">[hidden]</span>
|
||||
<% end %>
|
||||
<button type="button" class="button-secondary small-btn usn" @click="revealed = !revealed" x-text="revealed ? '<%= t('.hide') %>' : '<%= t('.reveal') %>'"></button>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.5rem; font-size: 0.9rem;"><%= t ".trust_level" %></label>
|
||||
<%= text_field_tag :trust_level, @app.trust_level.to_s.titleize, readonly: true, autocomplete: "off" %>
|
||||
</div>
|
||||
<%# Redirect URIs %>
|
||||
<section class="section-card">
|
||||
<h3><%= t ".redirect_uris" %></h3>
|
||||
<div class="redirect-uri-list">
|
||||
<% @app.redirect_uri.split.each do |uri| %>
|
||||
<div class="redirect-uri-row">
|
||||
<%= copy_to_clipboard uri, label: t(".click_to_copy_redirect_uri") do %>
|
||||
<code class="credential-value"><%= uri %></code>
|
||||
<% 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 %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%# Collaborators (owner only) %>
|
||||
<% if policy(@app).manage_collaborators? %>
|
||||
<section class="section-card">
|
||||
<h3><%= t(".collaborators") %></h3>
|
||||
<div class="collaborator-list">
|
||||
<% if @collaborators.any? %>
|
||||
<% @collaborators.each do |collab| %>
|
||||
<div class="collaborator-row">
|
||||
<span class="collaborator-email">
|
||||
<% if admin? %>
|
||||
<%= link_to collab.identity.primary_email, backend_identity_path(collab.identity) %>
|
||||
<% else %>
|
||||
<%= collab.identity.primary_email %>
|
||||
<% end %>
|
||||
</span>
|
||||
<%= 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')}')" } %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<% unless @pending_invitations_for_app.any? %>
|
||||
<p style="color: var(--pico-muted-color);"><%= t(".no_collaborators") %></p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @pending_invitations_for_app.any? %>
|
||||
<p class="label-upper" style="margin: <%= @collaborators.any? ? '1.25rem' : '0' %> 0 0.75rem;">
|
||||
<%= t(".pending_invitations_heading") %>
|
||||
</p>
|
||||
<div class="collaborator-list">
|
||||
<% @pending_invitations_for_app.each do |inv| %>
|
||||
<div class="collaborator-row">
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<span class="collaborator-email"><%= inv.invited_email %></span>
|
||||
<span class="badge pending"><%= t(".pending_badge") %></span>
|
||||
</div>
|
||||
<%= button_to t(".cancel_invitation"),
|
||||
cancel_developer_app_collaborator_invite_path(@app, inv),
|
||||
method: :post, class: "secondary small-btn" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="add-collaborator">
|
||||
<p class="label-upper" style="margin: 0 0 0.75rem;"><%= t(".add_collaborator") %></p>
|
||||
<%= 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 %>
|
||||
<button type="submit" class="approve small-btn"><%= t(".add_button") %></button>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<%# Activity Log (async) %>
|
||||
<section class="section-card">
|
||||
<h3><%= t(".activity_log") %></h3>
|
||||
<%= render Components::BootlegTurboButton.new(
|
||||
activity_log_developer_app_path(@app),
|
||||
text: t(".show_activity"),
|
||||
id: "activity-log-container"
|
||||
) %>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div style="display: flex; gap: 0.75rem; flex-wrap: wrap;">
|
||||
<%= 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") } %>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
60
app/views/public_activity/program/_change.html.erb
Normal file
60
app/views/public_activity/program/_change.html.erb
Normal file
|
|
@ -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 <strong>HQ Official</strong>"
|
||||
else
|
||||
phrases << "changed trust level to <strong>#{ERB::Util.html_escape(diff[:to])}</strong>"
|
||||
end
|
||||
when "name"
|
||||
phrases << "renamed app from <strong>#{ERB::Util.html_escape(diff[:from])}</strong> to <strong>#{ERB::Util.html_escape(diff[:to])}</strong>"
|
||||
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: <code>#{added.map { |s| ERB::Util.html_escape(s) }.join(', ')}</code>"
|
||||
end
|
||||
if removed.any?
|
||||
phrases << "removed scopes: <code>#{removed.map { |s| ERB::Util.html_escape(s) }.join(', ')}</code>"
|
||||
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 <abbr title=\"#{tooltip}\">#{added.size} redirect #{added.size == 1 ? 'URI' : 'URIs'}</abbr>"
|
||||
end
|
||||
if removed.any?
|
||||
tooltip = removed.map { |u| ERB::Util.html_escape(u) }.join("\n")
|
||||
phrases << "removed <abbr title=\"#{tooltip}\">#{removed.size} redirect #{removed.size == 1 ? 'URI' : 'URIs'}</abbr>"
|
||||
end
|
||||
when "onboarding_scenario"
|
||||
if diff[:to].present?
|
||||
phrases << "changed onboarding scenario to <strong>#{ERB::Util.html_escape(diff[:to])}</strong>"
|
||||
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 %>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<%= render Components::PublicActivity::Snippet.new(activity, owner_component: Components::IdentityMention.new(activity.owner)) do %>
|
||||
accepted the collaboration invitation.
|
||||
<% end %>
|
||||
|
|
@ -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 <strong><%= activity.parameters[:cancelled_email] || activity.parameters["cancelled_email"] %></strong>.
|
||||
<% else %>
|
||||
cancelled an invitation.
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<%= render Components::PublicActivity::Snippet.new(activity, owner_component: Components::IdentityMention.new(activity.owner)) do %>
|
||||
declined the collaboration invitation.
|
||||
<% end %>
|
||||
|
|
@ -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 <strong><%= activity.parameters[:invited_email] || activity.parameters["invited_email"] %></strong> to collaborate.
|
||||
<% else %>
|
||||
invited a new collaborator.
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
|
@ -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 <strong><%= activity.parameters[:removed_email] || activity.parameters["removed_email"] %></strong>.
|
||||
<% else %>
|
||||
removed a collaborator.
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
|
@ -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 %>
|
||||
|
|
|
|||
|
|
@ -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 %>
|
||||
|
|
|
|||
|
|
@ -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 %>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<%= render Components::PublicActivity::Snippet.new(activity, owner_component: Components::IdentityMention.new(activity.owner)) do %>
|
||||
rotated credentials.
|
||||
<% end %>
|
||||
|
|
@ -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 %>
|
||||
|
|
|
|||
|
|
@ -14,5 +14,5 @@
|
|||
reason_name = Verification::DocumentVerification::REJECTION_REASON_NAMES[reason] || reason
|
||||
%> for <%= reason_name.downcase %><%
|
||||
end
|
||||
%>.
|
||||
%>.
|
||||
<% end %>
|
||||
|
|
|
|||
|
|
@ -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: "<strong>Note:</strong> Your app will initially be created with <code>community_untrusted</code> trust level. <br/> This means users will see a scarier consent screen when authorizing your app. <br/> 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
11
db/migrate/20260226200001_create_program_collaborators.rb
Normal file
11
db/migrate/20260226200001_create_program_collaborators.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
34
db/schema.rb
generated
34
db/schema.rb
generated
|
|
@ -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
|
||||
|
|
|
|||
25
spec/factories/backend_users.rb
Normal file
25
spec/factories/backend_users.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
12
spec/factories/program_collaborators.rb
Normal file
12
spec/factories/program_collaborators.rb
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue