0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-21 00:52:43 -05:00

feat(server): Add publicUsers toggle for user search (#14330)

* feat(server): Add publicUsers toggle for user search

* tests

* docs: add check:typescript for web PR checklist

* return auth.user when publicUsers is false - app testing

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Sam Holton 2024-11-26 10:51:01 -05:00 committed by GitHub
parent b6ec79cbdd
commit 5417e34fb6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 93 additions and 11 deletions

View file

@ -11,6 +11,7 @@ When contributing code through a pull request, please check the following:
- [ ] `npm run lint` (linting via ESLint)
- [ ] `npm run format` (formatting via Prettier)
- [ ] `npm run check:svelte` (Type checking via SvelteKit)
- [ ] `npm run check:typescript` (check typescript)
- [ ] `npm test` (unit tests)
## Documentation

View file

@ -133,6 +133,7 @@ describe('/server', () => {
userDeleteDelay: 7,
isInitialized: true,
externalDomain: '',
publicUsers: true,
isOnboarded: false,
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',

View file

@ -224,6 +224,8 @@
"send_welcome_email": "Send welcome email",
"server_external_domain_settings": "External domain",
"server_external_domain_settings_description": "Domain for public shared links, including http(s)://",
"server_public_users": "Public Users",
"server_public_users_description": "All users (name and email) are listed when adding a user to shared albums. When disabled, the user list will only be available to admin users.",
"server_settings": "Server Settings",
"server_settings_description": "Manage server settings",
"server_welcome_message": "Welcome message",

View file

@ -20,6 +20,7 @@ class ServerConfigDto {
required this.mapDarkStyleUrl,
required this.mapLightStyleUrl,
required this.oauthButtonText,
required this.publicUsers,
required this.trashDays,
required this.userDeleteDelay,
});
@ -38,6 +39,8 @@ class ServerConfigDto {
String oauthButtonText;
bool publicUsers;
int trashDays;
int userDeleteDelay;
@ -51,6 +54,7 @@ class ServerConfigDto {
other.mapDarkStyleUrl == mapDarkStyleUrl &&
other.mapLightStyleUrl == mapLightStyleUrl &&
other.oauthButtonText == oauthButtonText &&
other.publicUsers == publicUsers &&
other.trashDays == trashDays &&
other.userDeleteDelay == userDeleteDelay;
@ -64,11 +68,12 @@ class ServerConfigDto {
(mapDarkStyleUrl.hashCode) +
(mapLightStyleUrl.hashCode) +
(oauthButtonText.hashCode) +
(publicUsers.hashCode) +
(trashDays.hashCode) +
(userDeleteDelay.hashCode);
@override
String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, mapDarkStyleUrl=$mapDarkStyleUrl, mapLightStyleUrl=$mapLightStyleUrl, oauthButtonText=$oauthButtonText, trashDays=$trashDays, userDeleteDelay=$userDeleteDelay]';
String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, mapDarkStyleUrl=$mapDarkStyleUrl, mapLightStyleUrl=$mapLightStyleUrl, oauthButtonText=$oauthButtonText, publicUsers=$publicUsers, trashDays=$trashDays, userDeleteDelay=$userDeleteDelay]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -79,6 +84,7 @@ class ServerConfigDto {
json[r'mapDarkStyleUrl'] = this.mapDarkStyleUrl;
json[r'mapLightStyleUrl'] = this.mapLightStyleUrl;
json[r'oauthButtonText'] = this.oauthButtonText;
json[r'publicUsers'] = this.publicUsers;
json[r'trashDays'] = this.trashDays;
json[r'userDeleteDelay'] = this.userDeleteDelay;
return json;
@ -100,6 +106,7 @@ class ServerConfigDto {
mapDarkStyleUrl: mapValueOfType<String>(json, r'mapDarkStyleUrl')!,
mapLightStyleUrl: mapValueOfType<String>(json, r'mapLightStyleUrl')!,
oauthButtonText: mapValueOfType<String>(json, r'oauthButtonText')!,
publicUsers: mapValueOfType<bool>(json, r'publicUsers')!,
trashDays: mapValueOfType<int>(json, r'trashDays')!,
userDeleteDelay: mapValueOfType<int>(json, r'userDeleteDelay')!,
);
@ -156,6 +163,7 @@ class ServerConfigDto {
'mapDarkStyleUrl',
'mapLightStyleUrl',
'oauthButtonText',
'publicUsers',
'trashDays',
'userDeleteDelay',
};

View file

@ -15,30 +15,36 @@ class SystemConfigServerDto {
SystemConfigServerDto({
required this.externalDomain,
required this.loginPageMessage,
required this.publicUsers,
});
String externalDomain;
String loginPageMessage;
bool publicUsers;
@override
bool operator ==(Object other) => identical(this, other) || other is SystemConfigServerDto &&
other.externalDomain == externalDomain &&
other.loginPageMessage == loginPageMessage;
other.loginPageMessage == loginPageMessage &&
other.publicUsers == publicUsers;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(externalDomain.hashCode) +
(loginPageMessage.hashCode);
(loginPageMessage.hashCode) +
(publicUsers.hashCode);
@override
String toString() => 'SystemConfigServerDto[externalDomain=$externalDomain, loginPageMessage=$loginPageMessage]';
String toString() => 'SystemConfigServerDto[externalDomain=$externalDomain, loginPageMessage=$loginPageMessage, publicUsers=$publicUsers]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'externalDomain'] = this.externalDomain;
json[r'loginPageMessage'] = this.loginPageMessage;
json[r'publicUsers'] = this.publicUsers;
return json;
}
@ -53,6 +59,7 @@ class SystemConfigServerDto {
return SystemConfigServerDto(
externalDomain: mapValueOfType<String>(json, r'externalDomain')!,
loginPageMessage: mapValueOfType<String>(json, r'loginPageMessage')!,
publicUsers: mapValueOfType<bool>(json, r'publicUsers')!,
);
}
return null;
@ -102,6 +109,7 @@ class SystemConfigServerDto {
static const requiredKeys = <String>{
'externalDomain',
'loginPageMessage',
'publicUsers',
};
}

View file

@ -10825,6 +10825,9 @@
"oauthButtonText": {
"type": "string"
},
"publicUsers": {
"type": "boolean"
},
"trashDays": {
"type": "integer"
},
@ -10840,6 +10843,7 @@
"mapDarkStyleUrl",
"mapLightStyleUrl",
"oauthButtonText",
"publicUsers",
"trashDays",
"userDeleteDelay"
],
@ -12014,11 +12018,15 @@
},
"loginPageMessage": {
"type": "string"
},
"publicUsers": {
"type": "boolean"
}
},
"required": [
"externalDomain",
"loginPageMessage"
"loginPageMessage",
"publicUsers"
],
"type": "object"
},

View file

@ -928,6 +928,7 @@ export type ServerConfigDto = {
mapDarkStyleUrl: string;
mapLightStyleUrl: string;
oauthButtonText: string;
publicUsers: boolean;
trashDays: number;
userDeleteDelay: number;
};
@ -1222,6 +1223,7 @@ export type SystemConfigReverseGeocodingDto = {
export type SystemConfigServerDto = {
externalDomain: string;
loginPageMessage: string;
publicUsers: boolean;
};
export type SystemConfigStorageTemplateDto = {
enabled: boolean;

View file

@ -149,6 +149,7 @@ export interface SystemConfig {
server: {
externalDomain: string;
loginPageMessage: string;
publicUsers: boolean;
};
user: {
deleteDelay: number;
@ -296,6 +297,7 @@ export const defaults = Object.freeze<SystemConfig>({
server: {
externalDomain: '',
loginPageMessage: '',
publicUsers: true,
},
notifications: {
smtp: {

View file

@ -39,8 +39,8 @@ export class UserController {
@Get()
@Authenticated()
searchUsers(): Promise<UserResponseDto[]> {
return this.service.search();
searchUsers(@Auth() auth: AuthDto): Promise<UserResponseDto[]> {
return this.service.search(auth);
}
@Get('me')

View file

@ -144,6 +144,7 @@ export class ServerConfigDto {
isInitialized!: boolean;
isOnboarded!: boolean;
externalDomain!: string;
publicUsers!: boolean;
mapDarkStyleUrl!: string;
mapLightStyleUrl!: string;
}

View file

@ -404,6 +404,9 @@ class SystemConfigServerDto {
@IsString()
loginPageMessage!: string;
@IsBoolean()
publicUsers!: boolean;
}
class SystemConfigSmtpTransportDto {

View file

@ -169,6 +169,7 @@ describe(ServerService.name, () => {
isInitialized: undefined,
isOnboarded: false,
externalDomain: '',
publicUsers: true,
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
});

View file

@ -110,6 +110,7 @@ export class ServerService extends BaseService {
isInitialized,
isOnboarded: onboarding?.isOnboarded || false,
externalDomain: config.server.externalDomain,
publicUsers: config.server.publicUsers,
mapDarkStyleUrl: config.map.darkStyle,
mapLightStyleUrl: config.map.lightStyle,
};

View file

@ -133,6 +133,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
server: {
externalDomain: '',
loginPageMessage: '',
publicUsers: true,
},
storageTemplate: {
enabled: false,

View file

@ -38,9 +38,9 @@ describe(UserService.name, () => {
});
describe('getAll', () => {
it('should get all users', async () => {
it('admin should get all users', async () => {
userMock.getList.mockResolvedValue([userStub.admin]);
await expect(sut.search()).resolves.toEqual([
await expect(sut.search(authStub.admin)).resolves.toEqual([
expect.objectContaining({
id: authStub.admin.user.id,
email: authStub.admin.user.email,
@ -48,6 +48,29 @@ describe(UserService.name, () => {
]);
expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: false });
});
it('non-admin should get all users when publicUsers enabled', async () => {
userMock.getList.mockResolvedValue([userStub.user1]);
await expect(sut.search(authStub.user1)).resolves.toEqual([
expect.objectContaining({
id: authStub.user1.user.id,
email: authStub.user1.user.email,
}),
]);
expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: false });
});
it('non-admin user should only receive itself when publicUsers is disabled', async () => {
userMock.getList.mockResolvedValue([userStub.user1]);
systemMock.get.mockResolvedValue(systemConfigStub.publicUsersDisabled);
await expect(sut.search(authStub.user1)).resolves.toEqual([
expect.objectContaining({
id: authStub.user1.user.id,
email: authStub.user1.user.email,
}),
]);
expect(userMock.getList).not.toHaveBeenCalledWith({ withDeleted: false });
});
});
describe('get', () => {

View file

@ -19,8 +19,14 @@ import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/uti
@Injectable()
export class UserService extends BaseService {
async search(): Promise<UserResponseDto[]> {
const users = await this.userRepository.getList({ withDeleted: false });
async search(auth: AuthDto): Promise<UserResponseDto[]> {
const config = await this.getConfig({ withCache: false });
let users: UserEntity[] = [auth.user];
if (auth.user.isAdmin || config.server.publicUsers) {
users = await this.userRepository.getList({ withDeleted: false });
}
return users.map((user) => mapUser(user));
}

View file

@ -117,4 +117,9 @@ export const systemConfigStub = {
},
},
},
publicUsersDisabled: {
server: {
publicUsers: false,
},
},
} satisfies Record<string, DeepPartial<SystemConfig>>;

View file

@ -5,6 +5,7 @@
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { t } from 'svelte-i18n';
import { SettingInputFieldType } from '$lib/constants';
@ -44,6 +45,13 @@
isEdited={config.server.loginPageMessage !== savedConfig.server.loginPageMessage}
/>
<SettingSwitch
title={$t('admin.server_public_users')}
subtitle={$t('admin.server_public_users_description')}
{disabled}
bind:checked={config.server.publicUsers}
/>
<div class="ml-4">
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['server'] })}

View file

@ -34,6 +34,7 @@ export const serverConfig = writable<ServerConfig>({
externalDomain: '',
mapDarkStyleUrl: '',
mapLightStyleUrl: '',
publicUsers: true,
});
export const retrieveServerConfig = async () => {