diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 089b69c474..5d93a32b57 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -3019,6 +3019,12 @@ export interface ServerConfigDto { * @memberof ServerConfigDto */ 'isInitialized': boolean; + /** + * + * @type {boolean} + * @memberof ServerConfigDto + */ + 'isOnboarded': boolean; /** * * @type {string} @@ -15142,6 +15148,44 @@ export const ServerInfoApiAxiosParamCreator = function (configuration?: Configur + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + setAdminOnboarding: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/server-info/admin-onboarding`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -15233,6 +15277,15 @@ export const ServerInfoApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.pingServer(options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async setAdminOnboarding(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.setAdminOnboarding(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, } }; @@ -15307,6 +15360,14 @@ export const ServerInfoApiFactory = function (configuration?: Configuration, bas pingServer(options?: AxiosRequestConfig): AxiosPromise { return localVarFp.pingServer(options).then((request) => request(axios, basePath)); }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + setAdminOnboarding(options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.setAdminOnboarding(options).then((request) => request(axios, basePath)); + }, }; }; @@ -15396,6 +15457,16 @@ export class ServerInfoApi extends BaseAPI { public pingServer(options?: AxiosRequestConfig) { return ServerInfoApiFp(this.configuration).pingServer(options).then((request) => request(this.axios, this.basePath)); } + + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ServerInfoApi + */ + public setAdminOnboarding(options?: AxiosRequestConfig) { + return ServerInfoApiFp(this.configuration).setAdminOnboarding(options).then((request) => request(this.axios, this.basePath)); + } } diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 068aa8aa2d..39cf9a74ad 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/doc/ServerConfigDto.md b/mobile/openapi/doc/ServerConfigDto.md index b2406daf74..317431b9bb 100644 Binary files a/mobile/openapi/doc/ServerConfigDto.md and b/mobile/openapi/doc/ServerConfigDto.md differ diff --git a/mobile/openapi/doc/ServerInfoApi.md b/mobile/openapi/doc/ServerInfoApi.md index e8121a8001..cb5cf0fd3e 100644 Binary files a/mobile/openapi/doc/ServerInfoApi.md and b/mobile/openapi/doc/ServerInfoApi.md differ diff --git a/mobile/openapi/lib/api/server_info_api.dart b/mobile/openapi/lib/api/server_info_api.dart index b67045add1..77840acd19 100644 Binary files a/mobile/openapi/lib/api/server_info_api.dart and b/mobile/openapi/lib/api/server_info_api.dart differ diff --git a/mobile/openapi/lib/model/server_config_dto.dart b/mobile/openapi/lib/model/server_config_dto.dart index abe5983966..d93da96a23 100644 Binary files a/mobile/openapi/lib/model/server_config_dto.dart and b/mobile/openapi/lib/model/server_config_dto.dart differ diff --git a/mobile/openapi/test/server_config_dto_test.dart b/mobile/openapi/test/server_config_dto_test.dart index ffd373bf2f..813ac25656 100644 Binary files a/mobile/openapi/test/server_config_dto_test.dart and b/mobile/openapi/test/server_config_dto_test.dart differ diff --git a/mobile/openapi/test/server_info_api_test.dart b/mobile/openapi/test/server_info_api_test.dart index dac465116e..68cd1c348b 100644 Binary files a/mobile/openapi/test/server_info_api_test.dart and b/mobile/openapi/test/server_info_api_test.dart differ diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 240a2d3810..0cf34169be 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -4725,6 +4725,31 @@ ] } }, + "/server-info/admin-onboarding": { + "post": { + "operationId": "setAdminOnboarding", + "parameters": [], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Server Info" + ] + } + }, "/server-info/config": { "get": { "operationId": "getServerConfig", @@ -8599,6 +8624,9 @@ "isInitialized": { "type": "boolean" }, + "isOnboarded": { + "type": "boolean" + }, "loginPageMessage": { "type": "string" }, @@ -8614,6 +8642,7 @@ "oauthButtonText", "loginPageMessage", "isInitialized", + "isOnboarded", "externalDomain" ], "type": "object" diff --git a/server/src/domain/server-info/server-info.dto.ts b/server/src/domain/server-info/server-info.dto.ts index 32ac51aa43..ea0d03b881 100644 --- a/server/src/domain/server-info/server-info.dto.ts +++ b/server/src/domain/server-info/server-info.dto.ts @@ -86,6 +86,7 @@ export class ServerConfigDto { @ApiProperty({ type: 'integer' }) trashDays!: number; isInitialized!: boolean; + isOnboarded!: boolean; externalDomain!: string; } diff --git a/server/src/domain/server-info/server-info.service.spec.ts b/server/src/domain/server-info/server-info.service.spec.ts index d093399c77..a8cd82443a 100644 --- a/server/src/domain/server-info/server-info.service.spec.ts +++ b/server/src/domain/server-info/server-info.service.spec.ts @@ -1,8 +1,10 @@ +import { SystemMetadataKey } from '@app/infra/entities'; import { newCommunicationRepositoryMock, newServerInfoRepositoryMock, newStorageRepositoryMock, newSystemConfigRepositoryMock, + newSystemMetadataRepositoryMock, newUserRepositoryMock, } from '@test'; import { serverVersion } from '../domain.constant'; @@ -11,6 +13,7 @@ import { IServerInfoRepository, IStorageRepository, ISystemConfigRepository, + ISystemMetadataRepository, IUserRepository, } from '../repositories'; import { ServerInfoService } from './server-info.service'; @@ -22,6 +25,7 @@ describe(ServerInfoService.name, () => { let serverInfoMock: jest.Mocked; let storageMock: jest.Mocked; let userMock: jest.Mocked; + let systemMetadataMock: jest.Mocked; beforeEach(() => { configMock = newSystemConfigRepositoryMock(); @@ -29,8 +33,16 @@ describe(ServerInfoService.name, () => { serverInfoMock = newServerInfoRepositoryMock(); storageMock = newStorageRepositoryMock(); userMock = newUserRepositoryMock(); + systemMetadataMock = newSystemMetadataRepositoryMock(); - sut = new ServerInfoService(communicationMock, configMock, userMock, serverInfoMock, storageMock); + sut = new ServerInfoService( + communicationMock, + configMock, + userMock, + serverInfoMock, + storageMock, + systemMetadataMock, + ); }); it('should work', () => { @@ -184,12 +196,21 @@ describe(ServerInfoService.name, () => { loginPageMessage: '', oauthButtonText: 'Login with OAuth', trashDays: 30, + isInitialized: undefined, + isOnboarded: false, externalDomain: '', }); expect(configMock.load).toHaveBeenCalled(); }); }); + describe('setAdminOnboarding', () => { + it('should set admin onboarding to true', async () => { + await sut.setAdminOnboarding(); + expect(systemMetadataMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: true }); + }); + }); + describe('getStats', () => { it('should total up usage by user', async () => { userMock.getUserStats.mockResolvedValue([ diff --git a/server/src/domain/server-info/server-info.service.ts b/server/src/domain/server-info/server-info.service.ts index 7c3f707843..697e461de5 100644 --- a/server/src/domain/server-info/server-info.service.ts +++ b/server/src/domain/server-info/server-info.service.ts @@ -1,3 +1,4 @@ +import { SystemMetadataKey } from '@app/infra/entities'; import { ImmichLogger } from '@app/infra/logger'; import { Inject, Injectable } from '@nestjs/common'; import { DateTime } from 'luxon'; @@ -9,6 +10,7 @@ import { IServerInfoRepository, IStorageRepository, ISystemConfigRepository, + ISystemMetadataRepository, IUserRepository, UserStatsQueryResponse, } from '../repositories'; @@ -37,6 +39,7 @@ export class ServerInfoService { @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IServerInfoRepository) private repository: IServerInfoRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, + @Inject(ISystemMetadataRepository) private readonly systemMetadataRepository: ISystemMetadataRepository, ) { this.configCore = SystemConfigCore.create(configRepository); this.communicationRepository.on('connect', (userId) => this.handleConnect(userId)); @@ -79,16 +82,22 @@ export class ServerInfoService { async getConfig(): Promise { const config = await this.configCore.getConfig(); const isInitialized = await this.userRepository.hasAdmin(); + const onboarding = await this.systemMetadataRepository.get(SystemMetadataKey.ADMIN_ONBOARDING); return { loginPageMessage: config.server.loginPageMessage, trashDays: config.trash.days, oauthButtonText: config.oauth.buttonText, isInitialized, + isOnboarded: onboarding?.isOnboarded || false, externalDomain: config.server.externalDomain, }; } + setAdminOnboarding(): Promise { + return this.systemMetadataRepository.set(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: true }); + } + async getStatistics(): Promise { const userStats: UserStatsQueryResponse[] = await this.userRepository.getUserStats(); const serverStats = new ServerStatsResponseDto(); diff --git a/server/src/immich/controllers/server-info.controller.ts b/server/src/immich/controllers/server-info.controller.ts index 52e9ea8d26..66835501cc 100644 --- a/server/src/immich/controllers/server-info.controller.ts +++ b/server/src/immich/controllers/server-info.controller.ts @@ -9,7 +9,7 @@ import { ServerThemeDto, ServerVersionResponseDto, } from '@app/domain'; -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, HttpCode, HttpStatus, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AdminRoute, Authenticated, PublicRoute } from '../app.guard'; import { UseValidation } from '../app.utils'; @@ -67,4 +67,11 @@ export class ServerInfoController { getSupportedMediaTypes(): ServerMediaTypesResponseDto { return this.service.getSupportedMediaTypes(); } + + @AdminRoute() + @Post('admin-onboarding') + @HttpCode(HttpStatus.NO_CONTENT) + setAdminOnboarding(): Promise { + return this.service.setAdminOnboarding(); + } } diff --git a/server/src/infra/entities/system-metadata.entity.ts b/server/src/infra/entities/system-metadata.entity.ts index 623806db79..24e9f83c74 100644 --- a/server/src/infra/entities/system-metadata.entity.ts +++ b/server/src/infra/entities/system-metadata.entity.ts @@ -11,8 +11,10 @@ export class SystemMetadataEntity { export enum SystemMetadataKey { REVERSE_GEOCODING_STATE = 'reverse-geocoding-state', + ADMIN_ONBOARDING = 'admin-onboarding', } export interface SystemMetadata extends Record { [SystemMetadataKey.REVERSE_GEOCODING_STATE]: { lastUpdate?: string; lastImportFileName?: string }; + [SystemMetadataKey.ADMIN_ONBOARDING]: { isOnboarded: boolean }; } diff --git a/server/test/api/index.ts b/server/test/api/index.ts index 21987c5004..d13f2425e8 100644 --- a/server/test/api/index.ts +++ b/server/test/api/index.ts @@ -5,6 +5,7 @@ import { assetApi } from './asset-api'; import { authApi } from './auth-api'; import { libraryApi } from './library-api'; import { partnerApi } from './partner-api'; +import { serverInfoApi } from './server-info-api'; import { sharedLinkApi } from './shared-link-api'; import { userApi } from './user-api'; @@ -14,6 +15,7 @@ export const api = { apiKeyApi, assetApi, libraryApi, + serverInfoApi, sharedLinkApi, albumApi, userApi, diff --git a/server/test/api/server-info-api.ts b/server/test/api/server-info-api.ts new file mode 100644 index 0000000000..f885bc856f --- /dev/null +++ b/server/test/api/server-info-api.ts @@ -0,0 +1,10 @@ +import { ServerConfigDto } from '@app/domain'; +import request from 'supertest'; + +export const serverInfoApi = { + getConfig: async (server: any) => { + const res = await request(server).get('/server-info/config'); + expect(res.status).toBe(200); + return res.body as ServerConfigDto; + }, +}; diff --git a/server/test/e2e/server-info.e2e-spec.ts b/server/test/e2e/server-info.e2e-spec.ts index d2d54d0790..fb0ad90c89 100644 --- a/server/test/e2e/server-info.e2e-spec.ts +++ b/server/test/e2e/server-info.e2e-spec.ts @@ -98,6 +98,7 @@ describe(`${ServerInfoController.name} (e2e)`, () => { trashDays: 30, isInitialized: true, externalDomain: '', + isOnboarded: false, }); }); }); @@ -167,4 +168,19 @@ describe(`${ServerInfoController.name} (e2e)`, () => { }); }); }); + + describe('POST /server-info/admin-onboarding', () => { + it('should set admin onboarding', async () => { + const config = await api.serverInfoApi.getConfig(server); + expect(config.isOnboarded).toBe(false); + + const { status } = await request(server) + .post('/server-info/admin-onboarding') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(204); + + const newConfig = await api.serverInfoApi.getConfig(server); + expect(newConfig.isOnboarded).toBe(true); + }); + }); }); diff --git a/server/test/repositories/index.ts b/server/test/repositories/index.ts index f625dc5213..d7a7f3e0c4 100644 --- a/server/test/repositories/index.ts +++ b/server/test/repositories/index.ts @@ -19,6 +19,7 @@ export * from './smart-info.repository.mock'; export * from './storage.repository.mock'; export * from './system-config.repository.mock'; export * from './system-info.repository.mock'; +export * from './system-metadata.repository.mock'; export * from './tag.repository.mock'; export * from './user-token.repository.mock'; export * from './user.repository.mock'; diff --git a/server/test/repositories/system-metadata.repository.mock.ts b/server/test/repositories/system-metadata.repository.mock.ts new file mode 100644 index 0000000000..fc4207da6f --- /dev/null +++ b/server/test/repositories/system-metadata.repository.mock.ts @@ -0,0 +1,8 @@ +import { ISystemMetadataRepository } from '@app/domain'; + +export const newSystemMetadataRepositoryMock = (): jest.Mocked => { + return { + get: jest.fn(), + set: jest.fn(), + }; +}; diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 089b69c474..5d93a32b57 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -3019,6 +3019,12 @@ export interface ServerConfigDto { * @memberof ServerConfigDto */ 'isInitialized': boolean; + /** + * + * @type {boolean} + * @memberof ServerConfigDto + */ + 'isOnboarded': boolean; /** * * @type {string} @@ -15142,6 +15148,44 @@ export const ServerInfoApiAxiosParamCreator = function (configuration?: Configur + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + setAdminOnboarding: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/server-info/admin-onboarding`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -15233,6 +15277,15 @@ export const ServerInfoApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.pingServer(options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async setAdminOnboarding(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.setAdminOnboarding(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, } }; @@ -15307,6 +15360,14 @@ export const ServerInfoApiFactory = function (configuration?: Configuration, bas pingServer(options?: AxiosRequestConfig): AxiosPromise { return localVarFp.pingServer(options).then((request) => request(axios, basePath)); }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + setAdminOnboarding(options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.setAdminOnboarding(options).then((request) => request(axios, basePath)); + }, }; }; @@ -15396,6 +15457,16 @@ export class ServerInfoApi extends BaseAPI { public pingServer(options?: AxiosRequestConfig) { return ServerInfoApiFp(this.configuration).pingServer(options).then((request) => request(this.axios, this.basePath)); } + + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ServerInfoApi + */ + public setAdminOnboarding(options?: AxiosRequestConfig) { + return ServerInfoApiFp(this.configuration).setAdminOnboarding(options).then((request) => request(this.axios, this.basePath)); + } } diff --git a/web/src/lib/assets/settings-outline.svg b/web/src/lib/assets/settings-outline.svg new file mode 100644 index 0000000000..7253589414 --- /dev/null +++ b/web/src/lib/assets/settings-outline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/lib/components/admin-page/settings/setting-switch.svelte b/web/src/lib/components/admin-page/settings/setting-switch.svelte index 302e1a88c8..6797423a55 100644 --- a/web/src/lib/components/admin-page/settings/setting-switch.svelte +++ b/web/src/lib/components/admin-page/settings/setting-switch.svelte @@ -16,7 +16,7 @@
-