Truncate access review roles with badge list
Long role strings in the access review table broke row layout when drivers joined many roles into one comma-separated value. Expose roles as a string array in GraphQL by splitting the stored role at the API layer, and render the first three roles as badges with a "+X more" popover for the rest. Closes ENG-459. Signed-off-by: Ludovic Vielle <ludovic@probo.com>
This commit is contained in:
@@ -41,7 +41,7 @@ func insertAccessReviewEntry(t *testing.T, ctx context.Context, client *pg.Clien
|
||||
AccessReviewCampaignSourceID: fx.campaignSourceID,
|
||||
Email: accountKey,
|
||||
FullName: "Snapshot User",
|
||||
Role: "member",
|
||||
Roles: []string{"member"},
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||
|
||||
@@ -37,7 +37,7 @@ type (
|
||||
IdentityID *gid.GID `db:"identity_id"`
|
||||
Email string `db:"email"`
|
||||
FullName string `db:"full_name"`
|
||||
Role string `db:"role"`
|
||||
Roles []string `db:"roles"`
|
||||
JobTitle string `db:"job_title"`
|
||||
IsAdmin bool `db:"is_admin"`
|
||||
MFAStatus MFAStatus `db:"mfa_status"`
|
||||
@@ -125,7 +125,7 @@ SELECT
|
||||
identity_id,
|
||||
email,
|
||||
full_name,
|
||||
role,
|
||||
roles,
|
||||
job_title,
|
||||
is_admin,
|
||||
mfa_status,
|
||||
@@ -192,7 +192,7 @@ INSERT INTO
|
||||
identity_id,
|
||||
email,
|
||||
full_name,
|
||||
role,
|
||||
roles,
|
||||
job_title,
|
||||
is_admin,
|
||||
mfa_status,
|
||||
@@ -222,7 +222,7 @@ VALUES (
|
||||
@identity_id,
|
||||
@email,
|
||||
@full_name,
|
||||
@role,
|
||||
COALESCE(@roles, '{}'::TEXT[]),
|
||||
@job_title,
|
||||
@is_admin,
|
||||
@mfa_status,
|
||||
@@ -254,7 +254,7 @@ VALUES (
|
||||
"identity_id": e.IdentityID,
|
||||
"email": e.Email,
|
||||
"full_name": e.FullName,
|
||||
"role": e.Role,
|
||||
"roles": e.Roles,
|
||||
"job_title": e.JobTitle,
|
||||
"is_admin": e.IsAdmin,
|
||||
"mfa_status": e.MFAStatus,
|
||||
@@ -346,7 +346,7 @@ SELECT
|
||||
identity_id,
|
||||
email,
|
||||
full_name,
|
||||
role,
|
||||
roles,
|
||||
job_title,
|
||||
is_admin,
|
||||
mfa_status,
|
||||
@@ -414,7 +414,7 @@ SELECT
|
||||
identity_id,
|
||||
email,
|
||||
full_name,
|
||||
role,
|
||||
roles,
|
||||
job_title,
|
||||
is_admin,
|
||||
mfa_status,
|
||||
@@ -630,7 +630,7 @@ INSERT INTO access_review_entries (
|
||||
identity_id,
|
||||
email,
|
||||
full_name,
|
||||
role,
|
||||
roles,
|
||||
job_title,
|
||||
is_admin,
|
||||
mfa_status,
|
||||
@@ -659,7 +659,7 @@ INSERT INTO access_review_entries (
|
||||
@identity_id,
|
||||
@email,
|
||||
@full_name,
|
||||
@role,
|
||||
COALESCE(@roles, '{}'::TEXT[]),
|
||||
@job_title,
|
||||
@is_admin,
|
||||
@mfa_status,
|
||||
@@ -683,7 +683,7 @@ INSERT INTO access_review_entries (
|
||||
ON CONFLICT (access_review_campaign_source_id, account_key) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
full_name = EXCLUDED.full_name,
|
||||
role = EXCLUDED.role,
|
||||
roles = EXCLUDED.roles,
|
||||
job_title = EXCLUDED.job_title,
|
||||
is_admin = EXCLUDED.is_admin,
|
||||
mfa_status = EXCLUDED.mfa_status,
|
||||
@@ -706,7 +706,7 @@ ON CONFLICT (access_review_campaign_source_id, account_key) DO UPDATE SET
|
||||
"identity_id": e.IdentityID,
|
||||
"email": e.Email,
|
||||
"full_name": e.FullName,
|
||||
"role": e.Role,
|
||||
"roles": e.Roles,
|
||||
"job_title": e.JobTitle,
|
||||
"is_admin": e.IsAdmin,
|
||||
"mfa_status": e.MFAStatus,
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.gearno.de/kit/pg"
|
||||
@@ -152,7 +153,7 @@ func TestAccessReviewEntry_Upsert_FreezesDecidedFields(t *testing.T) {
|
||||
originalFlags := []coredata.AccessReviewEntryFlag{coredata.AccessReviewEntryFlagNew}
|
||||
originalEmail := "old@example.com"
|
||||
originalFullName := "Old Name"
|
||||
originalRole := "viewer"
|
||||
originalRoles := []string{"viewer"}
|
||||
|
||||
t0 := time.Now().UTC().Truncate(time.Microsecond)
|
||||
|
||||
@@ -165,7 +166,7 @@ func TestAccessReviewEntry_Upsert_FreezesDecidedFields(t *testing.T) {
|
||||
AccessReviewCampaignSourceID: fx.campaignSourceID,
|
||||
Email: originalEmail,
|
||||
FullName: originalFullName,
|
||||
Role: originalRole,
|
||||
Roles: originalRoles,
|
||||
JobTitle: "",
|
||||
IsAdmin: false,
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
@@ -214,7 +215,7 @@ func TestAccessReviewEntry_Upsert_FreezesDecidedFields(t *testing.T) {
|
||||
t2 := decisionTime.Add(1 * time.Hour)
|
||||
secondEmail := "new@example.com"
|
||||
secondFullName := "New Name"
|
||||
secondRole := "admin"
|
||||
secondRoles := []string{"admin"}
|
||||
refresh := &coredata.AccessReviewEntry{
|
||||
ID: gid.New(tenantID, coredata.AccessReviewEntryEntityType), // ignored by ON CONFLICT
|
||||
OrganizationID: fx.organizationID,
|
||||
@@ -222,7 +223,7 @@ func TestAccessReviewEntry_Upsert_FreezesDecidedFields(t *testing.T) {
|
||||
AccessReviewCampaignSourceID: fx.campaignSourceID,
|
||||
Email: secondEmail,
|
||||
FullName: secondFullName,
|
||||
Role: secondRole,
|
||||
Roles: secondRoles,
|
||||
JobTitle: "",
|
||||
IsAdmin: true,
|
||||
MFAStatus: coredata.MFAStatusEnabled,
|
||||
@@ -271,7 +272,7 @@ func TestAccessReviewEntry_Upsert_FreezesDecidedFields(t *testing.T) {
|
||||
// Columns that ARE refreshed on every poll.
|
||||
assert.Equal(t, secondEmail, loaded.Email)
|
||||
assert.Equal(t, secondFullName, loaded.FullName)
|
||||
assert.Equal(t, secondRole, loaded.Role)
|
||||
assert.Equal(t, secondRoles, loaded.Roles)
|
||||
assert.True(t, loaded.IsAdmin)
|
||||
assert.Equal(t, coredata.MFAStatusEnabled, loaded.MFAStatus)
|
||||
assert.Equal(t, coredata.AccessReviewEntryAuthMethodSSO, loaded.AuthMethod)
|
||||
@@ -303,7 +304,7 @@ func TestAccessReviewEntry_Upsert_RefreshesSourceTrackingFields(t *testing.T) {
|
||||
AccessReviewCampaignSourceID: fx.campaignSourceID,
|
||||
Email: "old@example.com",
|
||||
FullName: "Old Name",
|
||||
Role: "viewer",
|
||||
Roles: []string{"viewer"},
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||
@@ -329,7 +330,7 @@ func TestAccessReviewEntry_Upsert_RefreshesSourceTrackingFields(t *testing.T) {
|
||||
AccessReviewCampaignSourceID: fx.campaignSourceID,
|
||||
Email: "new@example.com",
|
||||
FullName: "New Name",
|
||||
Role: "admin",
|
||||
Roles: []string{"admin"},
|
||||
MFAStatus: coredata.MFAStatusEnabled,
|
||||
AuthMethod: coredata.AccessReviewEntryAuthMethodSSO,
|
||||
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||
@@ -356,7 +357,7 @@ func TestAccessReviewEntry_Upsert_RefreshesSourceTrackingFields(t *testing.T) {
|
||||
// Source-tracking columns advanced to the second poll's values.
|
||||
assert.Equal(t, "new@example.com", loaded.Email)
|
||||
assert.Equal(t, "New Name", loaded.FullName)
|
||||
assert.Equal(t, "admin", loaded.Role)
|
||||
assert.Equal(t, []string{"admin"}, loaded.Roles)
|
||||
assert.Equal(t, coredata.MFAStatusEnabled, loaded.MFAStatus)
|
||||
assert.Equal(t, coredata.AccessReviewEntryAuthMethodSSO, loaded.AuthMethod)
|
||||
|
||||
@@ -393,7 +394,7 @@ func TestAccessReviewEntry_Upsert_InsertsActiveAccount(t *testing.T) {
|
||||
AccessReviewCampaignSourceID: fx.campaignSourceID,
|
||||
Email: "active@example.com",
|
||||
FullName: "Active User",
|
||||
Role: "member",
|
||||
Roles: []string{"member"},
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||
@@ -424,3 +425,52 @@ func TestAccessReviewEntry_Upsert_InsertsActiveAccount(t *testing.T) {
|
||||
assert.Nil(t, loaded.DecidedBy)
|
||||
assert.Nil(t, loaded.DecidedAt)
|
||||
}
|
||||
|
||||
func TestAccessReviewEntry_Upsert_NilRolesWritesEmptyArray(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := test.PGClient(t)
|
||||
ctx := context.Background()
|
||||
fx := seedAccessReviewEntryFixture(t, ctx, client)
|
||||
|
||||
tenantID := fx.scope.GetTenantID()
|
||||
t0 := time.Now().UTC().Truncate(time.Microsecond)
|
||||
|
||||
entryID := gid.New(tenantID, coredata.AccessReviewEntryEntityType)
|
||||
entry := &coredata.AccessReviewEntry{
|
||||
ID: entryID,
|
||||
OrganizationID: fx.organizationID,
|
||||
AccessReviewCampaignID: fx.campaignID,
|
||||
AccessReviewCampaignSourceID: fx.campaignSourceID,
|
||||
Email: "nil-roles@example.com",
|
||||
FullName: "Nil Roles User",
|
||||
Roles: nil,
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||
ExternalID: "ext-nil-roles",
|
||||
AccountKey: "nil-roles@example.com",
|
||||
IncrementalTag: coredata.AccessReviewEntryIncrementalTagNew,
|
||||
Flags: []coredata.AccessReviewEntryFlag{},
|
||||
FlagReasons: []string{},
|
||||
Decision: coredata.AccessReviewEntryDecisionPending,
|
||||
CreatedAt: t0,
|
||||
UpdatedAt: t0,
|
||||
}
|
||||
|
||||
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
|
||||
return entry.Upsert(ctx, tx, fx.scope)
|
||||
}))
|
||||
|
||||
var roles []string
|
||||
|
||||
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
|
||||
return conn.QueryRow(
|
||||
ctx,
|
||||
`SELECT roles FROM access_review_entries WHERE id = @id`,
|
||||
pgx.StrictNamedArgs{"id": entryID},
|
||||
).Scan(&roles)
|
||||
}))
|
||||
|
||||
assert.Equal(t, []string{}, roles)
|
||||
}
|
||||
|
||||
26
pkg/coredata/migrations/20260612T160000Z.sql
Normal file
26
pkg/coredata/migrations/20260612T160000Z.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
--
|
||||
-- Permission to use, copy, modify, and/or distribute this software for any
|
||||
-- purpose with or without fee is hereby granted, provided that the above
|
||||
-- copyright notice and this permission notice appear in all copies.
|
||||
--
|
||||
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
-- PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
ALTER TABLE access_review_entries
|
||||
ADD COLUMN roles TEXT[] NOT NULL DEFAULT '{}';
|
||||
|
||||
UPDATE access_review_entries
|
||||
SET roles = CASE
|
||||
WHEN role = '' THEN '{}'::TEXT[]
|
||||
WHEN position(', ' in role) > 0 THEN string_to_array(role, ', ')
|
||||
ELSE ARRAY[role]
|
||||
END;
|
||||
|
||||
ALTER TABLE access_review_entries
|
||||
DROP COLUMN role;
|
||||
Reference in New Issue
Block a user