added some better ux

This commit is contained in:
Chris
2025-11-29 09:46:57 -08:00
parent da69c2b702
commit 2e7010e558
29 changed files with 623 additions and 136 deletions

View File

@@ -0,0 +1,18 @@
# frozen_string_literal: true
module AddOns
class Filter
extend LightService::Action
expects :params, :add_ons
promises :add_ons
executed do |context|
query = context.params[:q].to_s.strip
if query.present?
context.add_ons = context.add_ons.where("add_ons.name ILIKE ?", "%#{query}%")
end
end
end
end

View File

@@ -0,0 +1,14 @@
# frozen_string_literal: true
module AddOns
class List
extend LightService::Organizer
def self.call(account_user:, params: {})
with(account_user: account_user, params: params).reduce(
AddOns::VisibleToUser,
AddOns::Filter
)
end
end
end

View File

@@ -4,12 +4,19 @@ module AddOns
class VisibleToUser
extend LightService::Action
expects :user, :account
expects :account_user
promises :add_ons
executed do |context|
user = context.user
account = context.account
account_user = context.account_user
user = account_user.user
account = account_user.account
# Admins can see all add_ons in the account
if account_user.admin?
context.add_ons = AddOn.joins(:cluster).where(clusters: { account_id: account.id })
next context
end
# If account has no teams, user can see all add_ons
if account.teams.empty?

View File

@@ -0,0 +1,18 @@
# frozen_string_literal: true
module Clusters
class Filter
extend LightService::Action
expects :params, :clusters
promises :clusters
executed do |context|
query = context.params[:q].to_s.strip
if query.present?
context.clusters = context.clusters.where("clusters.name ILIKE ?", "%#{query}%")
end
end
end
end

View File

@@ -0,0 +1,14 @@
# frozen_string_literal: true
module Clusters
class List
extend LightService::Organizer
def self.call(account_user:, params: {})
with(account_user: account_user, params: params).reduce(
Clusters::VisibleToUser,
Clusters::Filter
)
end
end
end

View File

@@ -4,12 +4,19 @@ module Clusters
class VisibleToUser
extend LightService::Action
expects :user, :account
expects :account_user
promises :clusters
executed do |context|
user = context.user
account = context.account
account_user = context.account_user
user = account_user.user
account = account_user.account
# Admins can see all clusters in the account
if account_user.admin?
context.clusters = Cluster.where(account_id: account.id)
next context
end
# If account has no teams, user can see all clusters
if account.teams.empty?

View File

@@ -0,0 +1,18 @@
# frozen_string_literal: true
module Projects
class Filter
extend LightService::Action
expects :params, :projects
promises :projects
executed do |context|
query = context.params[:q].to_s.strip
if query.present?
context.projects = context.projects.where("projects.name ILIKE ?", "%#{query}%")
end
end
end
end

View File

@@ -0,0 +1,14 @@
# frozen_string_literal: true
module Projects
class List
extend LightService::Organizer
def self.call(account_user:, params: {})
with(account_user: account_user, params: params).reduce(
Projects::VisibleToUser,
Projects::Filter
)
end
end
end

View File

@@ -4,12 +4,19 @@ module Projects
class VisibleToUser
extend LightService::Action
expects :user, :account
expects :account_user
promises :projects
executed do |context|
user = context.user
account = context.account
account_user = context.account_user
user = account_user.user
account = account_user.account
# Admins can see all projects in the account
if account_user.admin?
context.projects = Project.joins(:cluster).where(clusters: { account_id: account.id })
next context
end
# If account has no teams, user can see all projects
if account.teams.empty?

View File

@@ -8,6 +8,7 @@ class Accounts::TeamsController < ApplicationController
def show
@pagy, @team_memberships = pagy(@team.team_memberships)
@tab = params[:tab] || "clusters"
end
def new

View File

@@ -4,9 +4,14 @@ class AddOnsController < ApplicationController
# GET /add_ons
def index
add_ons = AddOns::VisibleToUser.execute(user: current_user, account: current_account).add_ons
add_ons = AddOns::List.call(account_user: current_account_user, params: params).add_ons
@pagy, @add_ons = pagy(add_ons)
respond_to do |format|
format.html
format.json { render json: @add_ons.map { |a| { id: a.id, name: a.name } } }
end
# Uncomment to authorize with Pundit
# authorize @add_ons
end
@@ -120,7 +125,7 @@ class AddOnsController < ApplicationController
# Use callbacks to share common setup or constraints between actions.
def set_add_on
add_ons = AddOns::VisibleToUser.execute(user: current_user, account: current_account).add_ons
add_ons = AddOns::VisibleToUser.execute(account_user: current_account_user).add_ons
@add_on = add_ons.find(params[:id])
@service = K8::Helm::Service.create_from_add_on(K8::Connection.new(@add_on, current_user))
rescue ActiveRecord::RecordNotFound

View File

@@ -23,6 +23,12 @@ class ApplicationController < ActionController::Base
end
helper_method :current_account
def current_account_user
return nil unless user_signed_in? && current_account
@current_account_user ||= AccountUser.find_by(user: current_user, account: current_account)
end
helper_method :current_account_user
def time_ago(t)
if t.present?
"#{time_ago_in_words(t)} ago"

View File

@@ -8,9 +8,14 @@ class ClustersController < ApplicationController
# GET /clusters
def index
sortable_column = params[:sort] || "created_at"
clusters = Clusters::VisibleToUser.execute(user: current_user, account: current_account).clusters
clusters = Clusters::List.call(account_user: current_account_user, params: params).clusters
@pagy, @clusters = pagy(clusters.order(sortable_column => "asc"))
respond_to do |format|
format.html
format.json { render json: @clusters.map { |c| { id: c.id, name: c.name } } }
end
# Uncomment to authorize with Pundit
# authorize @clusters
end
@@ -172,7 +177,7 @@ class ClustersController < ApplicationController
# Use callbacks to share common setup or constraints between actions.
def set_cluster
clusters = Clusters::VisibleToUser.execute(user: current_user, account: current_account).clusters
clusters = Clusters::VisibleToUser.execute(account_user: current_account_user).clusters
@cluster = clusters.find(params[:id])
# Uncomment to authorize with Pundit

View File

@@ -6,9 +6,14 @@ class ProjectsController < ApplicationController
# GET /projects
def index
sortable_column = params[:sort] || "created_at"
projects = Projects::VisibleToUser.execute(user: current_user, account: current_account).projects
projects = Projects::List.call(account_user: current_account_user, params: params).projects
@pagy, @projects = pagy(projects.order(sortable_column => "asc"))
respond_to do |format|
format.html
format.json { render json: @projects.map { |p| { id: p.id, name: p.name } } }
end
# Uncomment to authorize with Pundit
# authorize @projects
end
@@ -87,7 +92,7 @@ class ProjectsController < ApplicationController
# Use callbacks to share common setup or constraints between actions.
def set_project
projects = Projects::VisibleToUser.execute(user: current_user, account: current_account).projects
projects = Projects::VisibleToUser.execute(account_user: current_account_user).projects
@project = projects.find(params[:id])
# Uncomment to authorize with Pundit

View File

@@ -48,7 +48,7 @@ export default class extends Controller {
createDropdown() {
const dropdown = document.createElement('ul')
dropdown.className = 'hidden absolute z-10 w-full mt-1 menu bg-base-200 block rounded-box shadow-lg max-h-[300px] overflow-y-auto'
dropdown.className = 'hidden absolute z-10 w-full mt-1 menu bg-neutral block rounded-box shadow-lg max-h-[300px] overflow-y-auto'
return dropdown
}

View File

@@ -0,0 +1,93 @@
import AsyncSearchDropdownController from "./components/async_search_dropdown_controller"
export default class extends AsyncSearchDropdownController {
static values = {
url: String,
addUrl: String,
resourceType: String,
turboFrame: String
}
async fetchResults(query) {
const url = `${this.urlValue}.json?q=${encodeURIComponent(query)}`
const response = await fetch(url, {
headers: {
'Accept': 'application/json'
}
})
if (!response.ok) {
throw new Error('Failed to search resources')
}
return await response.json()
}
renderItem(resource) {
return `
<div class="flex items-center gap-2">
<div class="flex-1">
<div class="font-medium">${this.escapeHtml(resource.name)}</div>
</div>
</div>
`
}
async onItemSelect(resource, itemElement) {
try {
itemElement.classList.add('opacity-50')
itemElement.innerHTML = `
<div class="flex items-center gap-2">
<span class="loading loading-spinner loading-sm"></span>
<span>Adding ${this.escapeHtml(resource.name)}...</span>
</div>
`
const response = await fetch(this.addUrlValue, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': this.getCsrfToken()
},
body: JSON.stringify({
resourceable_type: this.resourceTypeValue,
resourceable_id: resource.id
})
})
if (!response.ok) {
throw new Error('Failed to add resource')
}
// Close the modal
const modal = this.element.closest('dialog')
if (modal) {
modal.close()
}
// Reload the turbo frame
if (this.hasTurboFrameValue) {
const frame = document.getElementById(this.turboFrameValue)
if (frame) {
frame.reload()
}
} else {
window.location.reload()
}
} catch (error) {
console.error('Error adding resource:', error)
alert('Failed to add resource. Please try again.')
itemElement.classList.remove('opacity-50')
}
}
escapeHtml(text) {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.content || ''
}
}

View File

@@ -21,4 +21,8 @@
class AccountUser < ApplicationRecord
belongs_to :user
belongs_to :account
def admin?
account.owner_id == user_id
end
end

View File

@@ -0,0 +1,49 @@
<div class="card card-bordered bg-base-100">
<div class="card-body text-center py-16">
<div class="flex flex-col items-center gap-6">
<h2 class="text-3xl font-bold">Organize access with teams</h2>
<p class="text-base-content/70 max-w-2xl">
Teams let you group members and control which resources they can access. Perfect for managing permissions as your organization scales.
</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 mt-8 max-w-4xl">
<div class="flex flex-col items-center gap-3">
<div class="w-16 h-16 rounded-full bg-base-200 flex items-center justify-center">
<iconify-icon icon="lucide:shield-check" class="text-2xl text-primary"></iconify-icon>
</div>
<h3 class="font-semibold text-lg">Granular permissions</h3>
<p class="text-base-content/70 text-sm">
Assign clusters, projects, and add-ons to specific teams. Members only see what they need.
</p>
</div>
<div class="flex flex-col items-center gap-3">
<div class="w-16 h-16 rounded-full bg-base-200 flex items-center justify-center">
<iconify-icon icon="lucide:user-plus" class="text-2xl text-primary"></iconify-icon>
</div>
<h3 class="font-semibold text-lg">Simple onboarding</h3>
<p class="text-base-content/70 text-sm">
Add someone to a team and they instantly get access to all the right resources. No manual setup required.
</p>
</div>
<div class="flex flex-col items-center gap-3">
<div class="w-16 h-16 rounded-full bg-base-200 flex items-center justify-center">
<iconify-icon icon="lucide:folder-tree" class="text-2xl text-primary"></iconify-icon>
</div>
<h3 class="font-semibold text-lg">Scale with confidence</h3>
<p class="text-base-content/70 text-sm">
Structure teams by role, project, or however works best. Easily adjust as your organization evolves.
</p>
</div>
</div>
<div class="flex gap-3 mt-8">
<%= link_to new_team_path, class: "btn btn-primary" do %>
<iconify-icon icon="lucide:plus"></iconify-icon>
Create your first team
<% end %>
</div>
</div>
</div>
</div>

View File

@@ -1,40 +1,55 @@
<div class="overflow-auto">
<table class="table mt-2 rounded-box" data-component="table">
<thead>
<tr>
<th>
<span class="text-sm font-medium text-base-content/80">Name</span>
</th>
<th>
<span class="text-sm font-medium text-base-content/80">Members</span>
</th>
<th>
<span class="text-sm font-medium text-base-content/80">Resources</span>
</th>
<th></th>
</tr>
</thead>
<tbody>
<% teams.each do |team| %>
<tr class="cursor-pointer hover:bg-base-200/40" onclick="window.location='<%= team_path(team) %>'">
<td>
<div class="font-medium"><%= team.name %></div>
</td>
<td>
<div class="font-medium"><%= team.users.count %></div>
</td>
<td>
<div class="font-medium"><%= team.team_resources.count %></div>
</td>
<td>
<div class="text-sm capitalize">
<%= button_to team_path(team), method: :delete, data: { confirm: "Are you sure?" }, class: "btn btn-square btn-sm btn-danger min-w-max" do %>
<iconify-icon icon="lucide:trash" height="18" class="text-error"></iconify-icon>
<% end %>
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% if teams.empty? %>
<%= render "accounts/teams/empty" %>
<% else %>
<div aria-label="Card" class="card card-bordered bg-base-100">
<div class="card-body">
<div class="text-right px-5 pt-5">
<%= link_to new_team_path, class: "btn btn-primary btn-sm" do %>
<iconify-icon icon="lucide:plus" height="16"></iconify-icon>
<span class="hidden sm:inline">Create Team</span>
<% end %>
</div>
<div class="overflow-auto">
<table class="table mt-2 rounded-box" data-component="table">
<thead>
<tr>
<th>
<span class="text-sm font-medium text-base-content/80">Name</span>
</th>
<th>
<span class="text-sm font-medium text-base-content/80">Members</span>
</th>
<th>
<span class="text-sm font-medium text-base-content/80">Resources</span>
</th>
<th></th>
</tr>
</thead>
<tbody>
<% teams.each do |team| %>
<tr class="cursor-pointer hover:bg-base-200/40" onclick="window.location='<%= team_path(team) %>'">
<td>
<div class="font-medium"><%= team.name %></div>
</td>
<td>
<div class="font-medium"><%= team.users.count %></div>
</td>
<td>
<div class="font-medium"><%= team.team_resources.count %></div>
</td>
<td>
<div class="text-sm capitalize">
<%= button_to team_path(team), method: :delete, data: { confirm: "Are you sure?" }, class: "btn btn-square btn-sm btn-danger min-w-max" do %>
<iconify-icon icon="lucide:trash" height="18" class="text-error"></iconify-icon>
<% end %>
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
</div>
<% end %>

View File

@@ -0,0 +1,29 @@
<%= turbo_frame_tag("team_resources_#{team.id}", data: { turbo_tabs_target: "content" }) do %>
<div class="pt-4">
<% if tab == "clusters" %>
<div class="flex justify-end mb-2">
<button class="btn btn-primary btn-sm" onclick="add_cluster_modal.showModal()">
<iconify-icon icon="lucide:plus" height="16"></iconify-icon>
<span class="hidden sm:inline">Add Cluster</span>
</button>
</div>
<%= render "accounts/teams/team_resources_table", team: team, resource_type: "Cluster" %>
<% elsif tab == "projects" %>
<div class="flex justify-end mb-2">
<button class="btn btn-primary btn-sm" onclick="add_project_modal.showModal()">
<iconify-icon icon="lucide:plus" height="16"></iconify-icon>
<span class="hidden sm:inline">Add Project</span>
</button>
</div>
<%= render "accounts/teams/team_resources_table", team: team, resource_type: "Project" %>
<% elsif tab == "addons" %>
<div class="flex justify-end mb-2">
<button class="btn btn-primary btn-sm" onclick="add_addon_modal.showModal()">
<iconify-icon icon="lucide:plus" height="16"></iconify-icon>
<span class="hidden sm:inline">Add Add-on</span>
</button>
</div>
<%= render "accounts/teams/team_resources_table", team: team, resource_type: "AddOn" %>
<% end %>
</div>
<% end %>

View File

@@ -0,0 +1,11 @@
<div role="tablist" class="tabs tabs-bordered" data-turbo-tabs-target="tabs">
<%= link_to team_path(team, tab: 'clusters'), class: "tab #{'tab-active' if tab == 'clusters'}" do %>
Clusters
<% end %>
<%= link_to team_path(team, tab: 'projects'), class: "tab #{'tab-active' if tab == 'projects'}" do %>
Projects
<% end %>
<%= link_to team_path(team, tab: 'addons'), class: "tab #{'tab-active' if tab == 'addons'}" do %>
Add-ons
<% end %>
</div>

View File

@@ -0,0 +1,37 @@
<% resources = team.team_resources.where(resourceable_type: resource_type) %>
<div class="overflow-auto">
<table class="table mt-2 rounded-box" data-component="table">
<thead>
<tr>
<th>
<span class="text-sm font-medium text-base-content/80">Name</span>
</th>
<th></th>
</tr>
</thead>
<tbody>
<% if resources.empty? %>
<tr>
<td colspan="2" class="text-center text-base-content/50 py-8">
No <%= resource_type.underscore.humanize.downcase.pluralize %> assigned to this team yet.
</td>
</tr>
<% else %>
<% resources.each do |team_resource| %>
<tr class="hover:bg-base-200/40">
<td>
<div class="font-medium"><%= team_resource.resourceable.name %></div>
</td>
<td>
<div class="text-sm capitalize">
<%= button_to team_team_resource_path(team, team_resource), method: :delete, data: { confirm: "Are you sure?" }, class: "btn btn-square btn-sm btn-danger min-w-max" do %>
<iconify-icon icon="lucide:trash" height="18" class="text-error"></iconify-icon>
<% end %>
</div>
</td>
</tr>
<% end %>
<% end %>
</tbody>
</table>
</div>

View File

@@ -32,24 +32,9 @@
</div>
<div class="mt-5">
<div aria-label="Card" class="card card-bordered bg-base-100">
<div class="card-body">
<div class="text-right px-5 pt-5">
<%= link_to new_team_path, class: "btn btn-primary btn-sm" do %>
<iconify-icon icon="lucide:plus" height="16"></iconify-icon>
<span class="hidden sm:inline">Create Team</span>
<% end %>
</div>
<div>
<%= tag.div id: ("teams" if @pagy.page == 1) do %>
<%= render "accounts/teams/list", teams: @teams, cached: true %>
<% end %>
</div>
</div>
</div>
<%= tag.div id: "teams" do %>
<%= render "accounts/teams/list", teams: @teams %>
<% end %>
</div>
<%= render 'shared/pagination', pagy: @pagy %>
</div>
<% end %>

View File

@@ -3,6 +3,18 @@
<%= turbo_stream_from [:team_memberships, @team] %>
<div>
<div class="breadcrumbs text-sm mb-4">
<ul>
<li>
<%= link_to teams_path, class: "inline-flex items-center gap-1" do %>
<iconify-icon icon="lucide:users" height="14"></iconify-icon>
Teams
<% end %>
</li>
<li><%= @team.name %></li>
</ul>
</div>
<div class="mb-6">
<div class="flex justify-between items-start">
<div>
@@ -54,16 +66,10 @@
<div class="mt-5">
<div aria-label="Card" class="card card-bordered bg-base-100">
<div class="card-body">
<div class="flex justify-between items-center px-5 pt-5">
<h3 class="text-lg font-semibold">Team Resources</h3>
<button class="btn btn-primary btn-sm" onclick="add_team_resource_modal.showModal()">
<iconify-icon icon="lucide:plus" height="16"></iconify-icon>
<span class="hidden sm:inline">Grant Access</span>
</button>
</div>
<div>
<%= render "accounts/teams/team_resources_list", team: @team, cached: true %>
<div class="px-5 pt-5" data-controller="turbo-tabs">
<h3 class="text-lg font-semibold mb-4">Team Resources</h3>
<%= render "accounts/teams/resource_tabs", team: @team, tab: @tab %>
<%= render "accounts/teams/resource_content", team: @team, tab: @tab %>
</div>
</div>
</div>
@@ -103,45 +109,104 @@
</form>
</dialog>
<!-- Add Team Resource Modal -->
<dialog aria-label="Modal" class="modal" id="add_team_resource_modal">
<!-- Add Cluster Modal -->
<dialog aria-label="Modal" class="modal" id="add_cluster_modal">
<div class="modal-box">
<form method="dialog">
<button aria-label="Close modal" class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2">
<iconify-icon icon="lucide:x" height="16"></iconify-icon>
</button>
</form>
<div class="mb-4 w-full text-xl font-bold">Grant Resource Access to <%= @team.name %></div>
<div class="mb-4 w-full text-xl font-bold">Add Cluster to <%= @team.name %></div>
<div>
<%= form_with(url: team_team_resources_path(@team)) do |form| %>
<div class="form-control mt-1 w-full">
<label class="label">
<span class="label-text">Resource Type</span>
</label>
<%= form.select :resourceable_type,
[["Cluster", "Cluster"], ["Project", "Project"], ["Add-on", "AddOn"]],
{ prompt: "Select resource type" },
class: "select select-bordered w-full focus:outline-offset-0",
data: { action: "change->team-resource#updateResourceOptions" } %>
<div class="form-control mt-1 w-full"
data-controller="team-resource-search"
data-team-resource-search-url-value="<%= clusters_path %>"
data-team-resource-search-add-url-value="<%= team_team_resources_path(@team) %>"
data-team-resource-search-resource-type-value="Cluster"
data-team-resource-search-turbo-frame-value="team_resources_<%= @team.id %>">
<label class="label">
<span class="label-text">Search for a cluster</span>
</label>
<div class="relative">
<input type="text"
placeholder="Type to search clusters..."
class="input input-bordered w-full focus:outline-offset-0" />
</div>
<label class="label">
<span class="label-text-alt text-warning">Adding a cluster will enable all projects and add-ons within that cluster.</span>
</label>
</div>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<div class="form-control mt-1 w-full">
<label class="label">
<span class="label-text">Resource</span>
</label>
<%= form.select :resourceable_id,
[],
{ prompt: "Select a resource" },
class: "select select-bordered w-full focus:outline-offset-0",
id: "resourceable_id_select" %>
<label class="label">
<span class="label-text-alt">Grant access to a specific resource.</span>
</label>
<!-- Add Project Modal -->
<dialog aria-label="Modal" class="modal" id="add_project_modal">
<div class="modal-box">
<form method="dialog">
<button aria-label="Close modal" class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2">
<iconify-icon icon="lucide:x" height="16"></iconify-icon>
</button>
</form>
<div class="mb-4 w-full text-xl font-bold">Add Project to <%= @team.name %></div>
<div>
<div class="form-control mt-1 w-full"
data-controller="team-resource-search"
data-team-resource-search-url-value="<%= projects_path %>"
data-team-resource-search-add-url-value="<%= team_team_resources_path(@team) %>"
data-team-resource-search-resource-type-value="Project"
data-team-resource-search-turbo-frame-value="team_resources_<%= @team.id %>">
<label class="label">
<span class="label-text">Search for a project</span>
</label>
<div class="relative">
<input type="text"
placeholder="Type to search projects..."
class="input input-bordered w-full focus:outline-offset-0" />
</div>
<div class="form-footer">
<%= form.button "Grant Access", class: "btn btn-primary" %>
<label class="label">
<span class="label-text-alt">Grant this team access to the selected project.</span>
</label>
</div>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<!-- Add Add-on Modal -->
<dialog aria-label="Modal" class="modal" id="add_addon_modal">
<div class="modal-box">
<form method="dialog">
<button aria-label="Close modal" class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2">
<iconify-icon icon="lucide:x" height="16"></iconify-icon>
</button>
</form>
<div class="mb-4 w-full text-xl font-bold">Add Add-on to <%= @team.name %></div>
<div>
<div class="form-control mt-1 w-full"
data-controller="team-resource-search"
data-team-resource-search-url-value="<%= add_ons_path %>"
data-team-resource-search-add-url-value="<%= team_team_resources_path(@team) %>"
data-team-resource-search-resource-type-value="AddOn"
data-team-resource-search-turbo-frame-value="team_resources_<%= @team.id %>">
<label class="label">
<span class="label-text">Search for an add-on</span>
</label>
<div class="relative">
<input type="text"
placeholder="Type to search add-ons..."
class="input input-bordered w-full focus:outline-offset-0" />
</div>
<% end %>
<label class="label">
<span class="label-text-alt">Grant this team access to the selected add-on.</span>
</label>
</div>
</div>
</div>
<form method="dialog" class="modal-backdrop">

View File

@@ -73,7 +73,9 @@
<% end %>
</summary>
<ul>
<% current_account.projects.order(created_at: :desc).each do |project| %>
<% Projects::VisibleToUser.execute(
account_user: current_account_user
).projects.order(created_at: :desc).each do |project| %>
<li>
<%= link_to root_projects_path(project), class: "hover:bg-base-content/15 #{'active' if request.path.start_with?(project_path(project))}" do %>
<div class="flex items-center gap-2">
@@ -104,7 +106,9 @@
<% end %>
</summary>
<ul>
<% current_account.clusters.order(created_at: :desc).each do |cluster| %>
<% Clusters::VisibleToUser.execute(
account_user: current_account_user
).clusters.order(created_at: :desc).each do |cluster| %>
<li>
<%= link_to cluster_path(cluster), class: "hover:bg-base-content/15 #{'active' if request.path.start_with?(cluster_path(cluster))}" do %>
<div class="flex items-center gap-2">
@@ -135,7 +139,9 @@
<% end %>
</summary>
<ul>
<% current_account.add_ons.order(created_at: :desc).each do |add_on| %>
<% AddOns::VisibleToUser.execute(
account_user: current_account_user
).add_ons.order(created_at: :desc).each do |add_on| %>
<li>
<%= link_to add_on_path(add_on), class: "hover:bg-base-content/15 #{'active' if request.path.start_with?(add_on_path(add_on))}" do %>
<div class="flex items-center gap-2">

View File

@@ -2,14 +2,14 @@
<% if content_for?(:title) %>
<%= yield :title %> |
<% end %>
Canine - An open source alternative to Heroku
Canine - A Developer-friendly PaaS for your Kubernetes
</title>
<meta name="description" content="Canine is an open source deployment platform that makes it easy to deploy and manage your applications.">
<% if content_for?(:og_title) %>
<meta property="og:title" content="<%= yield :og_title %>" />
<% else %>
<meta property="og:title" content="Canine - An open source alternative to Heroku" />
<meta property="og:title" content="Canine - A Developer-friendly PaaS for your Kubernetes" />
<% end %>
<% if content_for?(:og_url) %>

View File

@@ -3,19 +3,38 @@ require 'rails_helper'
RSpec.describe AddOns::VisibleToUser do
let!(:account) { create(:account) }
let!(:user) { create(:user) }
let!(:account_user) { AccountUser.find_by(user: user, account: account) || create(:account_user, user: user, account: account) }
let!(:cluster) { create(:cluster, account: account) }
before do
account.users << user
account.users << user unless account.users.include?(user)
end
describe '.execute' do
context 'when user is an admin (account owner)' do
let!(:add_on1) { create(:add_on, cluster: cluster) }
let!(:add_on2) { create(:add_on, cluster: cluster) }
let(:team) { create(:team, account: account) }
before do
account.update!(owner: user)
team # Create team so account has teams
end
it 'returns all add_ons in the account regardless of team membership' do
result = described_class.execute(account_user: account_user)
expect(result).to be_success
expect(result.add_ons).to match_array([ add_on1, add_on2 ])
end
end
context 'when account has no teams' do
let!(:add_on1) { create(:add_on, cluster: cluster) }
let!(:add_on2) { create(:add_on, cluster: cluster) }
it 'returns all add_ons in the account' do
result = described_class.execute(user: user, account: account)
result = described_class.execute(account_user: account_user)
expect(result).to be_success
expect(result.add_ons).to match_array([ add_on1, add_on2 ])
@@ -35,7 +54,7 @@ RSpec.describe AddOns::VisibleToUser do
end
it 'returns no add_ons' do
result = described_class.execute(user: user, account: account.reload)
result = described_class.execute(account_user: account_user)
expect(result).to be_success
expect(result.add_ons).to be_empty
@@ -49,7 +68,7 @@ RSpec.describe AddOns::VisibleToUser do
end
it 'returns only add_ons granted to user teams' do
result = described_class.execute(user: user, account: account)
result = described_class.execute(account_user: account_user)
expect(result).to be_success
expect(result.add_ons).to eq([ add_on1 ])
@@ -67,7 +86,7 @@ RSpec.describe AddOns::VisibleToUser do
end
it 'returns all add_ons in the granted cluster' do
result = described_class.execute(user: user, account: account)
result = described_class.execute(account_user: account_user)
expect(result).to be_success
expect(result.add_ons).to match_array([ add_on4, add_on5 ])
@@ -85,7 +104,7 @@ RSpec.describe AddOns::VisibleToUser do
end
it 'returns all accessible add_ons without duplicates' do
result = described_class.execute(user: user, account: account)
result = described_class.execute(account_user: account_user)
expect(result).to be_success
expect(result.add_ons).to match_array([ add_on1, add_on4 ])
@@ -101,7 +120,7 @@ RSpec.describe AddOns::VisibleToUser do
end
it 'returns add_ons from all teams user belongs to' do
result = described_class.execute(user: user, account: account)
result = described_class.execute(account_user: account_user)
expect(result).to be_success
expect(result.add_ons).to match_array([ add_on1, add_on2 ])

View File

@@ -3,18 +3,37 @@ require 'rails_helper'
RSpec.describe Clusters::VisibleToUser do
let!(:account) { create(:account) }
let!(:user) { create(:user) }
let!(:account_user) { AccountUser.find_by(user: user, account: account) || create(:account_user, user: user, account: account) }
before do
account.users << user
account.users << user unless account.users.include?(user)
end
describe '.execute' do
context 'when user is an admin (account owner)' do
let!(:cluster1) { create(:cluster, account: account) }
let!(:cluster2) { create(:cluster, account: account) }
let(:team) { create(:team, account: account) }
before do
account.update!(owner: user)
team # Create team so account has teams
end
it 'returns all clusters in the account regardless of team membership' do
result = described_class.execute(account_user: account_user)
expect(result).to be_success
expect(result.clusters).to match_array([ cluster1, cluster2 ])
end
end
context 'when account has no teams' do
let!(:cluster1) { create(:cluster, account: account) }
let!(:cluster2) { create(:cluster, account: account) }
it 'returns all clusters in the account' do
result = described_class.execute(user: user, account: account)
result = described_class.execute(account_user: account_user)
expect(result).to be_success
expect(result.clusters).to match_array([ cluster1, cluster2 ])
@@ -34,7 +53,7 @@ RSpec.describe Clusters::VisibleToUser do
end
it 'returns no clusters' do
result = described_class.execute(user: user, account: account.reload)
result = described_class.execute(account_user: account_user)
expect(result).to be_success
expect(result.clusters).to be_empty
@@ -48,7 +67,7 @@ RSpec.describe Clusters::VisibleToUser do
end
it 'returns only clusters granted to user teams' do
result = described_class.execute(user: user, account: account)
result = described_class.execute(account_user: account_user)
expect(result).to be_success
expect(result.clusters).to eq([ cluster1 ])
@@ -64,7 +83,7 @@ RSpec.describe Clusters::VisibleToUser do
end
it 'returns clusters from all teams user belongs to' do
result = described_class.execute(user: user, account: account)
result = described_class.execute(account_user: account_user)
expect(result).to be_success
expect(result.clusters).to match_array([ cluster1, cluster2 ])
@@ -80,7 +99,7 @@ RSpec.describe Clusters::VisibleToUser do
end
it 'returns unique clusters without duplicates' do
result = described_class.execute(user: user, account: account)
result = described_class.execute(account_user: account_user)
expect(result).to be_success
expect(result.clusters).to eq([ cluster1 ])

View File

@@ -9,9 +9,25 @@ RSpec.describe Projects::VisibleToUser do
let!(:project2) { create(:project, cluster:, account:) }
describe '.execute' do
context 'when user is an admin (account owner)' do
let(:team) { create(:team, account: account) }
before do
account.update!(owner: user)
team # Create team so account has teams
end
it 'returns all projects in the account regardless of team membership' do
result = described_class.execute(account_user: account_user)
expect(result).to be_success
expect(result.projects).to match_array([ project1, project2 ])
end
end
context 'when account has no teams' do
it 'returns all projects in the account' do
result = described_class.execute(user: user, account: account)
result = described_class.execute(account_user: account_user)
expect(result).to be_success
expect(result.projects).to match_array([ project1, project2 ])
@@ -25,7 +41,7 @@ RSpec.describe Projects::VisibleToUser do
context 'when user is not in any teams' do
it 'returns no projects' do
result = described_class.execute(user: user, account: account)
result = described_class.execute(account_user: account_user)
expect(result).to be_success
expect(result.projects).to be_empty
@@ -39,7 +55,7 @@ RSpec.describe Projects::VisibleToUser do
end
it 'returns only projects granted to user teams' do
result = described_class.execute(user: user, account: account)
result = described_class.execute(account_user: account_user)
expect(result).to be_success
expect(result.projects).to eq([ project1 ])
@@ -57,7 +73,7 @@ RSpec.describe Projects::VisibleToUser do
end
it 'returns all projects in the granted cluster' do
result = described_class.execute(user: user, account: account)
result = described_class.execute(account_user: account_user)
expect(result).to be_success
expect(result.projects).to match_array([ project4, project5 ])
@@ -75,7 +91,7 @@ RSpec.describe Projects::VisibleToUser do
end
it 'returns all accessible projects without duplicates' do
result = described_class.execute(user: user, account: account)
result = described_class.execute(account_user: account_user)
expect(result).to be_success
expect(result.projects).to match_array([ project1, project4 ])
@@ -91,7 +107,7 @@ RSpec.describe Projects::VisibleToUser do
end
it 'returns projects from all teams user belongs to' do
result = described_class.execute(user: user, account: account)
result = described_class.execute(account_user: account_user)
expect(result).to be_success
expect(result.projects).to match_array([ project1, project2 ])