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:
nora 2026-03-02 22:15:13 -05:00 committed by GitHub
parent 77152b525d
commit 9998147a4e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
65 changed files with 1876 additions and 599 deletions

View 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

View 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

View file

@ -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 }

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View 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

View file

@ -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

View file

@ -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()

View 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));
},
};
}

View file

@ -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"],

View file

@ -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;
}

View file

@ -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;
}

View 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; } }
}
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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;

View file

@ -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)

View 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

View file

@ -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

View file

@ -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]

View file

@ -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)

View 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

View file

@ -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

View file

@ -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>

View file

@ -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">

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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">&nbsp; [ APPS ]</p>
<% end %>

View 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 %>

View file

@ -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()">&#x2715;</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>

View file

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

View file

@ -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()">&#x2715;</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>

View file

@ -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>

View file

@ -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

View file

@ -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

View file

@ -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

View 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 %>

View file

@ -0,0 +1,3 @@
<%= render Components::PublicActivity::Snippet.new(activity, owner_component: Components::IdentityMention.new(activity.owner)) do %>
accepted the collaboration invitation.
<% end %>

View file

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

View file

@ -0,0 +1,3 @@
<%= render Components::PublicActivity::Snippet.new(activity, owner_component: Components::IdentityMention.new(activity.owner)) do %>
declined the collaboration invitation.
<% end %>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,3 @@
<%= render Components::PublicActivity::Snippet.new(activity, owner_component: Components::IdentityMention.new(activity.owner)) do %>
rotated credentials.
<% end %>

View file

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

View file

@ -14,5 +14,5 @@
reason_name = Verification::DocumentVerification::REJECTION_REASON_NAMES[reason] || reason
%> for <%= reason_name.downcase %><%
end
%>.
%>.
<% end %>

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View file

@ -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
View file

@ -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

View 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

View file

@ -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

View 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