Skip to content

Commit 77001c1

Browse files
authored
feat(front): add members download button (#664)
## 📝 Description - Add members download button on people page - Implement export members as csv endpoint - Add export members test ## ✅ Checklist - [x] I have tested this change - [ ] This change requires documentation update
1 parent 44964db commit 77001c1

File tree

9 files changed

+241
-12
lines changed

9 files changed

+241
-12
lines changed

front/assets/js/service_accounts/components/ServiceAccountsList.tsx

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -72,15 +72,23 @@ export const ServiceAccountsList = ({
7272
<div className="b">Service Accounts</div>
7373
</div>
7474
</div>
75-
{config.permissions.canManage && (
76-
<button
77-
className="btn btn-primary flex items-center"
78-
onClick={onCreateNew}
79-
>
80-
<span className="material-symbols-outlined mr2">smart_toy</span>
81-
Create Service Account
82-
</button>
83-
)}
75+
<div className="flex">
76+
{config.isOrgScope && (
77+
<a aria-label="Download service accounts as CSV" title="Download service accounts as CSV" className="pointer flex items-center btn-secondary btn nowrap mr2" href={config.urls.export}>
78+
<span className="material-symbols-outlined ">download</span>
79+
Download .csv
80+
</a>
81+
)}
82+
{config.permissions.canManage && (
83+
<button
84+
className="btn btn-primary flex items-center"
85+
onClick={onCreateNew}
86+
>
87+
<span className="material-symbols-outlined mr2">smart_toy</span>
88+
Create Service Account
89+
</button>
90+
)}
91+
</div>
8492
</div>
8593

8694
{serviceAccounts.length === 0 ? (

front/assets/js/service_accounts/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export class AppConfig {
1919
urls: {
2020
list: `/service_accounts`,
2121
create: `/service_accounts`,
22+
export: `/service_accounts/export`,
2223
update: (id: string) => `/service_accounts/${id}`,
2324
delete: (id: string) => `/service_accounts/${id}`,
2425
regenerateToken: (id: string) =>

front/assets/js/service_accounts/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export interface Config {
5959
urls: {
6060
list: string;
6161
create: string;
62+
export: string;
6263
update: (id: string) => string;
6364
delete: (id: string) => string;
6465
regenerateToken: (id: string) => string;

front/lib/front_web/controllers/people_controller.ex

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,12 @@ defmodule FrontWeb.PeopleController do
2424
plug(FetchPermissions, [scope: "org"] when action in @person_action)
2525
plug(PageAccess, [permissions: "organization.view"] when action in @person_action)
2626

27-
plug(FetchPermissions, [scope: "org"] when action in [:organization])
28-
plug(PageAccess, [permissions: "organization.view"] when action in [:organization])
27+
plug(FetchPermissions, [scope: "org"] when action in [:organization, :organization_users])
28+
29+
plug(
30+
PageAccess,
31+
[permissions: "organization.view"] when action in [:organization, :organization_users]
32+
)
2933

3034
plug(
3135
FetchPermissions,
@@ -1061,6 +1065,51 @@ defmodule FrontWeb.PeopleController do
10611065
end)
10621066
end
10631067

1068+
def organization_users(conn, _params) do
1069+
Watchman.benchmark("people.organization_users.duration", fn ->
1070+
org_id = conn.assigns.organization_id
1071+
1072+
page_size = 100
1073+
1074+
data =
1075+
Stream.unfold(0, fn
1076+
nil ->
1077+
nil
1078+
1079+
page_no ->
1080+
case Members.list_org_members(org_id, page_no: page_no, page_size: page_size) do
1081+
{:ok, {members, total_pages}} ->
1082+
{members, next_valid_page_or_nil(total_pages, page_no)}
1083+
1084+
_ ->
1085+
nil
1086+
end
1087+
end)
1088+
|> Enum.flat_map(& &1)
1089+
|> Enum.map(fn e ->
1090+
%{
1091+
"name" => e.name,
1092+
"email" => e.email,
1093+
"github_login" => e.github_login,
1094+
"bitbucket_login" => e.bitbucket_login,
1095+
"gitlab_login" => e.gitlab_login
1096+
}
1097+
end)
1098+
|> CSV.encode(
1099+
headers: [
1100+
"name",
1101+
"email",
1102+
"github_login",
1103+
"bitbucket_login",
1104+
"gitlab_login"
1105+
]
1106+
)
1107+
|> Enum.to_list()
1108+
1109+
send_download(conn, {:binary, data}, filename: "users.csv")
1110+
end)
1111+
end
1112+
10641113
def sync(conn, %{"format" => "json"}) do
10651114
Watchman.benchmark("sync.organization.duration", fn ->
10661115
org_id = conn.assigns.organization_id
@@ -1213,4 +1262,14 @@ defmodule FrontWeb.PeopleController do
12131262
email_regex = ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/
12141263
Regex.match?(email_regex, email)
12151264
end
1265+
1266+
defp next_valid_page_or_nil(total_pages, page) do
1267+
next_page_no = page + 1
1268+
1269+
if next_page_no <= total_pages do
1270+
next_page_no
1271+
else
1272+
nil
1273+
end
1274+
end
12161275
end

front/lib/front_web/controllers/service_account_controller.ex

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,4 +138,55 @@ defmodule FrontWeb.ServiceAccountController do
138138
|> json(%{error: message})
139139
end
140140
end
141+
142+
def export(conn, _params) do
143+
org_id = conn.assigns.organization_id
144+
145+
data =
146+
Stream.unfold(1, fn
147+
nil ->
148+
nil
149+
150+
page ->
151+
case Models.ServiceAccount.list(org_id, page) do
152+
{:ok, {service_accounts, total_pages}} ->
153+
{service_accounts, next_valid_page_or_nil(total_pages, page)}
154+
155+
_ ->
156+
nil
157+
end
158+
end)
159+
|> Enum.flat_map(& &1)
160+
|> Enum.map(fn e ->
161+
%{
162+
"name" => e.name,
163+
"description" => e.description,
164+
"deactivated" => e.deactivated,
165+
"created_at" => e.created_at,
166+
"updated_at" => e.created_at
167+
}
168+
end)
169+
|> CSV.encode(
170+
headers: [
171+
"name",
172+
"description",
173+
"deactivated",
174+
"created_at",
175+
"updated_at"
176+
]
177+
)
178+
|> Enum.to_list()
179+
180+
send_download(conn, {:binary, data}, filename: "service_accounts.csv")
181+
end
182+
183+
defp next_valid_page_or_nil(total_pages, page) do
184+
next_page_no = page + 1
185+
186+
if next_page_no <= total_pages do
187+
next_page_no
188+
else
189+
nil
190+
end
191+
end
141192
end

front/lib/front_web/router.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ defmodule FrontWeb.Router do
152152

153153
scope "/people" do
154154
get("/", PeopleController, :organization)
155+
get("/export", PeopleController, :organization_users)
155156
post("/", PeopleController, :create)
156157
post("/refresh", PeopleController, :refresh)
157158
post("/assign_role", PeopleController, :assign_role)
@@ -175,6 +176,7 @@ defmodule FrontWeb.Router do
175176
scope "/service_accounts" do
176177
get("/", ServiceAccountController, :index)
177178
post("/", ServiceAccountController, :create)
179+
get("/export", ServiceAccountController, :export)
178180
get("/:id", ServiceAccountController, :show)
179181
put("/:id", ServiceAccountController, :update)
180182
delete("/:id", ServiceAccountController, :delete)

front/lib/front_web/templates/people/members/members_list.html.eex

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,15 @@
3232
<div class="b">People</div>
3333
</div>
3434
</div>
35-
<%= render "members/_add_people_button.html", conn: @conn, org_scope?: @org_scope?, permissions: @permissions %>
35+
<div class="flex">
36+
<%= if @org_scope? do %>
37+
<%= link to: people_path(@conn, :organization_users), class: "pointer flex items-center btn-secondary btn nowrap mr2", aria_label: "Download members as CSV", title: "Download members as CSV" do %>
38+
<span class="material-symbols-outlined ">download</span>
39+
Download .csv
40+
<% end %>
41+
<% end %>
42+
<%= render "members/_add_people_button.html", conn: @conn, org_scope?: @org_scope?, permissions: @permissions %>
43+
</div>
3644
</div>
3745

3846
<div id="members">

front/test/front_web/controllers/people_controller_test.exs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,46 @@ defmodule FrontWeb.PeopleControllerTest do
3636
]
3737
end
3838

39+
describe "GET organization_users" do
40+
test "when the user can't access the org => returns 404", %{
41+
conn: conn
42+
} do
43+
PermissionPatrol.remove_all_permissions()
44+
45+
conn =
46+
conn
47+
|> get("/people/export")
48+
49+
assert html_response(conn, 404) =~ "404"
50+
end
51+
52+
test "when the user can access the org => send csv", %{
53+
conn: conn
54+
} do
55+
conn =
56+
conn
57+
|> get("/people/export")
58+
59+
assert response_content_type(conn, :csv)
60+
61+
rows =
62+
conn.resp_body
63+
|> String.split("\r\n", trim: true)
64+
|> CSV.decode!(validate_row_length: true, headers: true)
65+
|> Enum.to_list()
66+
67+
assert length(rows) == 8
68+
69+
first = List.first(rows)
70+
71+
assert Map.has_key?(first, "name")
72+
assert Map.has_key?(first, "email")
73+
assert Map.has_key?(first, "github_login")
74+
assert Map.has_key?(first, "bitbucket_login")
75+
assert Map.has_key?(first, "gitlab_login")
76+
end
77+
end
78+
3979
describe "GET show" do
4080
test "when the user can't access the org => returns 404", %{
4181
conn: conn,

front/test/front_web/controllers/service_account_controller_test.exs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,65 @@ defmodule FrontWeb.ServiceAccountControllerTest do
268268
end
269269
end
270270

271+
describe "GET /service_accounts/export" do
272+
test "requires service_accounts.view permission", %{
273+
conn: conn,
274+
org_id: org_id,
275+
user_id: user_id
276+
} do
277+
Support.Stubs.PermissionPatrol.remove_all_permissions()
278+
Support.Stubs.PermissionPatrol.add_permissions(org_id, user_id, ["organization.view"])
279+
280+
conn = get(conn, "/service_accounts/export")
281+
282+
assert html_response(conn, 404) =~ "Page not found"
283+
end
284+
285+
test "when the user can access the org => send csv", %{
286+
conn: conn,
287+
org_id: org_id,
288+
user_id: user_id
289+
} do
290+
Support.Stubs.PermissionPatrol.add_permissions(org_id, user_id, ["organization.view"])
291+
292+
expect(ServiceAccountMock, :describe_many, fn members ->
293+
service_accounts =
294+
Enum.map(members, fn member_id ->
295+
%InternalApi.ServiceAccount.ServiceAccount{
296+
id: member_id,
297+
name: "Test Service Account",
298+
description: "Test description",
299+
org_id: org_id,
300+
creator_id: "",
301+
created_at: %Google.Protobuf.Timestamp{seconds: 1_704_103_200},
302+
updated_at: %Google.Protobuf.Timestamp{seconds: 1_704_103_200},
303+
deactivated: false
304+
}
305+
end)
306+
307+
{:ok, service_accounts}
308+
end)
309+
310+
conn = get(conn, "/service_accounts/export")
311+
312+
rows =
313+
conn.resp_body
314+
|> String.split("\r\n", trim: true)
315+
|> CSV.decode!(validate_row_length: true, headers: true)
316+
|> Enum.to_list()
317+
318+
assert length(rows) == 3
319+
320+
first = List.first(rows)
321+
322+
assert Map.has_key?(first, "name")
323+
assert Map.has_key?(first, "description")
324+
assert Map.has_key?(first, "deactivated")
325+
assert Map.has_key?(first, "created_at")
326+
assert Map.has_key?(first, "updated_at")
327+
end
328+
end
329+
271330
describe "PUT /service_accounts/:id" do
272331
setup %{org_id: org_id, user_id: user_id} do
273332
Support.Stubs.PermissionPatrol.add_permissions(org_id, user_id, [

0 commit comments

Comments
 (0)