feat: display groups on the account page (#296)

Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
Kyle Mendell
2025-03-06 15:25:03 -06:00
committed by GitHub
parent 37b24bed91
commit 0f14a93e1d
14 changed files with 223 additions and 22 deletions

View File

@@ -27,9 +27,12 @@ func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.Jwt
group.GET("/users/:id", jwtAuthMiddleware.Add(true), uc.getUserHandler)
group.POST("/users", jwtAuthMiddleware.Add(true), uc.createUserHandler)
group.PUT("/users/:id", jwtAuthMiddleware.Add(true), uc.updateUserHandler)
group.GET("/users/:id/groups", jwtAuthMiddleware.Add(true), uc.getUserGroupsHandler)
group.PUT("/users/me", jwtAuthMiddleware.Add(false), uc.updateCurrentUserHandler)
group.DELETE("/users/:id", jwtAuthMiddleware.Add(true), uc.deleteUserHandler)
group.PUT("/users/:id/user-groups", jwtAuthMiddleware.Add(true), uc.updateUserGroups)
group.GET("/users/:id/profile-picture.png", uc.getUserProfilePictureHandler)
group.GET("/users/me/profile-picture.png", jwtAuthMiddleware.Add(false), uc.getCurrentUserProfilePictureHandler)
group.PUT("/users/:id/profile-picture", jwtAuthMiddleware.Add(true), uc.updateUserProfilePictureHandler)
@@ -46,6 +49,23 @@ type UserController struct {
appConfigService *service.AppConfigService
}
func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
userID := c.Param("id")
groups, err := uc.userService.GetUserGroups(userID)
if err != nil {
c.Error(err)
return
}
var groupsDto []dto.UserGroupDtoWithUsers
if err := dto.MapStructList(groups, &groupsDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, groupsDto)
}
func (uc *UserController) listUsersHandler(c *gin.Context) {
searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest
@@ -315,3 +335,25 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
c.JSON(http.StatusOK, userDto)
}
func (uc *UserController) updateUserGroups(c *gin.Context) {
var input dto.UserUpdateUserGroupDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
return
}
user, err := uc.userService.UpdateUserGroups(c.Param("id"), input.UserGroupIds)
if err != nil {
c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, userDto)
}

View File

@@ -139,7 +139,7 @@ func (ugc *UserGroupController) updateUsers(c *gin.Context) {
return
}
group, err := ugc.UserGroupService.UpdateUsers(c.Param("id"), input)
group, err := ugc.UserGroupService.UpdateUsers(c.Param("id"), input.UserIDs)
if err != nil {
c.Error(err)
return

View File

@@ -10,6 +10,7 @@ type UserDto struct {
LastName string `json:"lastName"`
IsAdmin bool `json:"isAdmin"`
CustomClaims []CustomClaimDto `json:"customClaims"`
UserGroups []UserGroupDto `json:"userGroups"`
LdapID *string `json:"ldapId"`
}
@@ -31,3 +32,7 @@ type OneTimeAccessEmailDto struct {
Email string `json:"email" binding:"required,email"`
RedirectPath string `json:"redirectPath"`
}
type UserUpdateUserGroupDto struct {
UserGroupIds []string `json:"userGroupIds" binding:"required"`
}

View File

@@ -4,6 +4,15 @@ import (
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
type UserGroupDto struct {
ID string `json:"id"`
FriendlyName string `json:"friendlyName"`
Name string `json:"name"`
CustomClaims []CustomClaimDto `json:"customClaims"`
LdapID *string `json:"ldapId"`
CreatedAt datatype.DateTime `json:"createdAt"`
}
type UserGroupDtoWithUsers struct {
ID string `json:"id"`
FriendlyName string `json:"friendlyName"`

View File

@@ -132,22 +132,18 @@ func (s *LdapService) SyncGroups() error {
LdapID: value.GetAttributeValue(uniqueIdentifierAttribute),
}
usersToAddDto := dto.UserGroupUpdateUsersDto{
UserIDs: membersUserId,
}
if databaseGroup.ID == "" {
newGroup, err := s.groupService.Create(syncGroup)
if err != nil {
log.Printf("Error syncing group %s: %s", syncGroup.Name, err)
} else {
if _, err = s.groupService.UpdateUsers(newGroup.ID, usersToAddDto); err != nil {
if _, err = s.groupService.UpdateUsers(newGroup.ID, membersUserId); err != nil {
log.Printf("Error syncing group %s: %s", syncGroup.Name, err)
}
}
} else {
_, err = s.groupService.Update(databaseGroup.ID, syncGroup, true)
_, err = s.groupService.UpdateUsers(databaseGroup.ID, usersToAddDto)
_, err = s.groupService.UpdateUsers(databaseGroup.ID, membersUserId)
if err != nil {
log.Printf("Error syncing group %s: %s", syncGroup.Name, err)
return err

View File

@@ -103,16 +103,16 @@ func (s *UserGroupService) Update(id string, input dto.UserGroupCreateDto, allow
return group, nil
}
func (s *UserGroupService) UpdateUsers(id string, input dto.UserGroupUpdateUsersDto) (group model.UserGroup, err error) {
func (s *UserGroupService) UpdateUsers(id string, userIds []string) (group model.UserGroup, err error) {
group, err = s.Get(id)
if err != nil {
return model.UserGroup{}, err
}
// Fetch the users based on UserIDs in input
// Fetch the users based on the userIds
var users []model.User
if len(input.UserIDs) > 0 {
if err := s.db.Where("id IN (?)", input.UserIDs).Find(&users).Error; err != nil {
if len(userIds) > 0 {
if err := s.db.Where("id IN (?)", userIds).Find(&users).Error; err != nil {
return model.UserGroup{}, err
}
}

View File

@@ -3,8 +3,6 @@ package service
import (
"errors"
"fmt"
"github.com/google/uuid"
"github.com/pocket-id/pocket-id/backend/internal/utils/image"
"io"
"log"
"net/url"
@@ -12,6 +10,9 @@ import (
"strings"
"time"
"github.com/google/uuid"
profilepicture "github.com/pocket-id/pocket-id/backend/internal/utils/image"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
@@ -48,7 +49,7 @@ func (s *UserService) ListUsers(searchTerm string, sortedPaginationRequest utils
func (s *UserService) GetUser(userID string) (model.User, error) {
var user model.User
err := s.db.Preload("CustomClaims").Where("id = ?", userID).First(&user).Error
err := s.db.Preload("UserGroups").Preload("CustomClaims").Where("id = ?", userID).First(&user).Error
return user, err
}
@@ -83,6 +84,14 @@ func (s *UserService) GetProfilePicture(userID string) (io.Reader, int64, error)
return defaultPicture, int64(defaultPicture.Len()), nil
}
func (s *UserService) GetUserGroups(userID string) ([]model.UserGroup, error) {
var user model.User
if err := s.db.Preload("UserGroups").Where("id = ?", userID).First(&user).Error; err != nil {
return nil, err
}
return user.UserGroups, nil
}
func (s *UserService) UpdateProfilePicture(userID string, file io.Reader) error {
// Validate the user ID to prevent directory traversal
if err := uuid.Validate(userID); err != nil {
@@ -269,6 +278,33 @@ func (s *UserService) ExchangeOneTimeAccessToken(token string, ipAddress, userAg
return oneTimeAccessToken.User, accessToken, nil
}
func (s *UserService) UpdateUserGroups(id string, userGroupIds []string) (user model.User, err error) {
user, err = s.GetUser(id)
if err != nil {
return model.User{}, err
}
// Fetch the groups based on userGroupIds
var groups []model.UserGroup
if len(userGroupIds) > 0 {
if err := s.db.Where("id IN (?)", userGroupIds).Find(&groups).Error; err != nil {
return model.User{}, err
}
}
// Replace the current groups with the new set of groups
if err := s.db.Model(&user).Association("UserGroups").Replace(groups); err != nil {
return model.User{}, err
}
// Save the updated user
if err := s.db.Save(&user).Error; err != nil {
return model.User{}, err
}
return user, nil
}
func (s *UserService) SetupInitialAdmin() (model.User, string, error) {
var userCount int64
if err := s.db.Model(&model.User{}).Count(&userCount).Error; err != nil {

View File

@@ -2,7 +2,6 @@
import AdvancedTable from '$lib/components/advanced-table.svelte';
import * as Table from '$lib/components/ui/table';
import UserGroupService from '$lib/services/user-group-service';
import type { OidcClient } from '$lib/types/oidc.type';
import type { Paginated } from '$lib/types/pagination.type';
import type { UserGroup } from '$lib/types/user-group.type';

View File

@@ -1,4 +1,5 @@
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import type { UserGroup } from '$lib/types/user-group.type';
import type { User, UserCreate } from '$lib/types/user.type';
import APIService from './api-service';
@@ -25,6 +26,11 @@ export default class UserService extends APIService {
return res.data as User;
}
async getUserGroups(userId: string) {
const res = await this.api.get(`/users/${userId}/groups`);
return res.data as UserGroup[];
}
async update(id: string, user: UserCreate) {
const res = await this.api.put(`/users/${id}`, user);
return res.data as User;
@@ -69,4 +75,9 @@ export default class UserService extends APIService {
async requestOneTimeAccessEmail(email: string, redirectPath?: string) {
await this.api.post('/one-time-access-email', { email, redirectPath });
}
async updateUserGroups(id: string, userGroupIds: string[]) {
const res = await this.api.put(`/users/${id}/user-groups`, { userGroupIds });
return res.data as User;
}
}

View File

@@ -1,4 +1,5 @@
import type { CustomClaim } from './custom-claim.type';
import type { UserGroup } from './user-group.type';
export type User = {
id: string;
@@ -7,6 +8,7 @@ export type User = {
firstName: string;
lastName: string;
isAdmin: boolean;
userGroups: UserGroup[];
customClaims: CustomClaim[];
ldapId?: string;
};

View File

@@ -1,11 +1,13 @@
<script lang="ts">
import { beforeNavigate } from '$app/navigation';
import { page } from '$app/stores';
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
import { openConfirmDialog } from '$lib/components/confirm-dialog';
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import Label from '$lib/components/ui/label/label.svelte';
import UserGroupSelection from '$lib/components/user-group-selection.svelte';
import OidcService from '$lib/services/oidc-service';
import UserGroupService from '$lib/services/user-group-service';
import clientSecretStore from '$lib/stores/client-secret-store';
@@ -15,8 +17,6 @@
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
import OidcForm from '../oidc-client-form.svelte';
import UserGroupSelection from '../user-group-selection.svelte';
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
let { data } = $props();
let client = $state({

View File

@@ -5,5 +5,8 @@ import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params, cookies }) => {
const userService = new UserService(cookies.get(ACCESS_TOKEN_COOKIE_NAME));
const user = await userService.get(params.id);
return user;
return {
user
};
};

View File

@@ -6,18 +6,34 @@
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import CustomClaimService from '$lib/services/custom-claim-service';
import UserGroupService from '$lib/services/user-group-service';
import UserService from '$lib/services/user-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { UserCreate } from '$lib/types/user.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideChevronLeft } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import UserGroupSelection from '$lib/components/user-group-selection.svelte';
import UserForm from '../user-form.svelte';
let { data } = $props();
let user = $state(data);
let user = $state({
...data.user,
userGroupIds: data.user.userGroups.map((g) => g.id)
});
const userService = new UserService();
const customClaimService = new CustomClaimService();
const userGroupService = new UserGroupService();
async function updateUserGroups(userIds: string[]) {
await userService
.updateUserGroups(user.id, userIds)
.then(() => toast.success('User groups updated successfully'))
.catch((e) => {
axiosErrorToast(e);
});
}
async function updateUser(updatedUser: UserCreate) {
let success = true;
@@ -80,6 +96,28 @@
</Card.Content>
</Card.Root>
<CollapsibleCard
id="user-groups"
title="User Groups"
description="Manage which groups this user belongs to."
>
{#await userGroupService.list() then groups}
<UserGroupSelection
{groups}
bind:selectedGroupIds={user.userGroupIds}
selectionDisabled={!!user.ldapId && $appConfigStore.ldapEnabled}
/>
{/await}
<div class="mt-5 flex justify-end">
<Button
on:click={() => updateUserGroups(user.userGroupIds)}
disabled={!!user.ldapId && $appConfigStore.ldapEnabled}
type="submit">Save</Button
>
</div>
</CollapsibleCard>
<CollapsibleCard
id="user-custom-claims"
title="Custom Claims"
@@ -87,6 +125,6 @@
>
<CustomClaimsInput bind:customClaims={user.customClaims} />
<div class="mt-5 flex justify-end">
<Button onclick={updateCustomClaims} type="submit">Save</Button>
<Button on:click={updateCustomClaims} type="submit">Save</Button>
</div>
</CollapsibleCard>

View File

@@ -1,5 +1,5 @@
import test, { expect } from '@playwright/test';
import { users } from './data';
import { users, userGroups } from './data';
import { cleanupBackend } from './utils/cleanup.util';
test.beforeEach(cleanupBackend);
@@ -142,7 +142,7 @@ test('Update user fails with already taken username', async ({ page }) => {
test('Update user custom claims', async ({ page }) => {
await page.goto(`/settings/admin/users/${users.craig.id}`);
await page.getByRole('button', { name: 'Expand card' }).click();
await page.getByRole('button', { name: 'Expand card' }).nth(1).click();
// Add two custom claims
await page.getByRole('button', { name: 'Add custom claim' }).click();
@@ -178,3 +178,63 @@ test('Update user custom claims', async ({ page }) => {
await expect(page.getByPlaceholder('Key').first()).toHaveValue('customClaim2');
await expect(page.getByPlaceholder('Value').first()).toHaveValue('customClaim2_value');
});
test('Update user group assignments', async ({ page }) => {
const user = users.craig;
await page.goto(`/settings/admin/users/${user.id}`);
// Increase the test timeout since this test is complex
test.setTimeout(30000);
// Expand the user groups section if it's collapsed
const expandButton = page.getByRole('button', { name: 'Expand card' }).first();
if (await expandButton.isVisible()) {
await expandButton.click();
}
// Wait for the user groups table to load
await page.waitForSelector('table');
// First, ensure we start with a clean state - uncheck any checked boxes
const developersCheckbox = page
.getByRole('row', { name: userGroups.developers.name })
.getByRole('checkbox');
const designersCheckbox = page
.getByRole('row', { name: userGroups.designers.name })
.getByRole('checkbox');
// Force click if needed to overcome element interception issues
if ((await developersCheckbox.getAttribute('data-state')) === 'checked') {
await developersCheckbox.click({ force: true });
}
if ((await designersCheckbox.getAttribute('data-state')) === 'checked') {
await designersCheckbox.click({ force: true });
}
// Save the changes to reset state if needed
await page.getByRole('button', { name: 'Save' }).nth(1).click();
// Wait for toast message to appear and disappear
await expect(page.getByRole('status')).toHaveText('User groups updated successfully');
await page.waitForTimeout(1000); // Wait for any animations or state changes
// Now add both groups (using force: true to avoid interception problems)
await developersCheckbox.click({ force: true });
await designersCheckbox.click({ force: true });
// Save the changes
await page.getByRole('button', { name: 'Save' }).nth(1).click();
// Verify success message
await expect(page.getByRole('status')).toHaveText('User groups updated successfully');
await page.reload();
await expect(
page.getByRole('row', { name: userGroups.developers.name }).getByRole('checkbox')
).toHaveAttribute('data-state', 'checked', { timeout: 10000 });
await expect(
page.getByRole('row', { name: userGroups.designers.name }).getByRole('checkbox')
).toHaveAttribute('data-state', 'checked', { timeout: 10000 });
});