diff --git a/api/src/graphql/generated/api/operations.ts b/api/src/graphql/generated/api/operations.ts index 0b733f36b..a97beac23 100755 --- a/api/src/graphql/generated/api/operations.ts +++ b/api/src/graphql/generated/api/operations.ts @@ -160,7 +160,8 @@ export function ApiSettingsInputSchema(): z.ZodObject; + /** A list of Unique Unraid Account ID's. */ + ssoUserIds?: InputMaybe>; }; export type ArrayType = Node & { @@ -408,6 +410,8 @@ export type ConnectSettingsValues = { * If false, the GraphQL sandbox is disabled and only the production API will be available. */ sandbox: Scalars['Boolean']['output']; + /** A list of Unique Unraid Account ID's. */ + ssoUserIds: Array; }; export type ConnectSignInInput = { @@ -2373,6 +2377,7 @@ export type ConnectSettingsValuesResolvers, ParentType, ContextType>; port?: Resolver, ParentType, ContextType>; sandbox?: Resolver; + ssoUserIds?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }>; diff --git a/api/src/graphql/schema/types/connect/connect.graphql b/api/src/graphql/schema/types/connect/connect.graphql index 60cf94694..1bba869d3 100644 --- a/api/src/graphql/schema/types/connect/connect.graphql +++ b/api/src/graphql/schema/types/connect/connect.graphql @@ -39,8 +39,6 @@ input SetupRemoteAccessInput { port: Port } - - input EnableDynamicRemoteAccessInput { url: AccessUrlInput! enabled: Boolean! @@ -59,59 +57,67 @@ type DynamicRemoteAccessStatus { } """ - Intersection type of ApiSettings and RemoteAccess +Intersection type of ApiSettings and RemoteAccess """ type ConnectSettingsValues { """ - If true, the GraphQL sandbox is enabled and available at /graphql. - If false, the GraphQL sandbox is disabled and only the production API will be available. + If true, the GraphQL sandbox is enabled and available at /graphql. + If false, the GraphQL sandbox is disabled and only the production API will be available. """ sandbox: Boolean! """ - A list of origins allowed to interact with the API. + A list of origins allowed to interact with the API. """ extraOrigins: [String!]! """ - The type of WAN access used for Remote Access. + The type of WAN access used for Remote Access. """ accessType: WAN_ACCESS_TYPE! """ - The type of port forwarding used for Remote Access. + The type of port forwarding used for Remote Access. """ forwardType: WAN_FORWARD_TYPE """ - The port used for Remote Access. + The port used for Remote Access. """ port: Port + """ + A list of Unique Unraid Account ID's. + """ + ssoUserIds: [String!]! } """ - Input should be a subset of ApiSettings that can be updated. - Some field combinations may be required or disallowed. Please refer to each field for more information. +Input should be a subset of ApiSettings that can be updated. +Some field combinations may be required or disallowed. Please refer to each field for more information. """ input ApiSettingsInput { """ - If true, the GraphQL sandbox will be enabled and available at /graphql. - If false, the GraphQL sandbox will be disabled and only the production API will be available. + If true, the GraphQL sandbox will be enabled and available at /graphql. + If false, the GraphQL sandbox will be disabled and only the production API will be available. """ sandbox: Boolean """ - A list of origins allowed to interact with the API. + A list of origins allowed to interact with the API. """ extraOrigins: [String!] """ - The type of WAN access to use for Remote Access. + The type of WAN access to use for Remote Access. """ accessType: WAN_ACCESS_TYPE """ - The type of port forwarding to use for Remote Access. + The type of port forwarding to use for Remote Access. """ forwardType: WAN_FORWARD_TYPE """ - The port to use for Remote Access. Not required for UPNP forwardType. Required for STATIC forwardType. - Ignored if accessType is DISABLED or forwardType is UPNP. + The port to use for Remote Access. Not required for UPNP forwardType. Required for STATIC forwardType. + Ignored if accessType is DISABLED or forwardType is UPNP. """ port: Port + """ + A list of Unique Unraid Account ID's. + """ + ssoUserIds: [String!] } type ConnectSettings implements Node { @@ -140,8 +146,8 @@ type Mutation { setAdditionalAllowedOrigins(input: AllowedOriginInput!): [String!]! setupRemoteAccess(input: SetupRemoteAccessInput!): Boolean! """ - Update the API settings. - Some setting combinations may be required or disallowed. Please refer to each setting for more information. + Update the API settings. + Some setting combinations may be required or disallowed. Please refer to each setting for more information. """ updateApiSettings(input: ApiSettingsInput!): ConnectSettingsValues! -} \ No newline at end of file +} diff --git a/api/src/store/modules/config.ts b/api/src/store/modules/config.ts index 38ae1b5e7..45dca1c12 100644 --- a/api/src/store/modules/config.ts +++ b/api/src/store/modules/config.ts @@ -219,6 +219,9 @@ export const config = createSlice({ stateAsArray.push(action.payload); state.remote.ssoSubIds = stateAsArray.join(','); }, + setSsoUsers(state, action: PayloadAction) { + state.remote.ssoSubIds = action.payload.filter((id) => id).join(','); + }, removeSsoUser(state, action: PayloadAction) { if (action.payload === null) { state.remote.ssoSubIds = ''; @@ -309,6 +312,7 @@ const { actions, reducer } = config; export const { addSsoUser, + setSsoUsers, updateUserConfig, updateAccessTokens, updateAllowedOrigins, @@ -324,6 +328,7 @@ export const { */ export const configUpdateActionsFlash = isAnyOf( addSsoUser, + setSsoUsers, updateUserConfig, updateAccessTokens, updateAllowedOrigins, diff --git a/api/src/unraid-api/graph/connect/connect-settings.service.ts b/api/src/unraid-api/graph/connect/connect-settings.service.ts index 16a97edf9..91355c992 100644 --- a/api/src/unraid-api/graph/connect/connect-settings.service.ts +++ b/api/src/unraid-api/graph/connect/connect-settings.service.ts @@ -18,7 +18,7 @@ import { WAN_FORWARD_TYPE, } from '@app/graphql/generated/api/types.js'; import { setupRemoteAccessThunk } from '@app/store/actions/setup-remote-access.js'; -import { updateAllowedOrigins, updateUserConfig } from '@app/store/modules/config.js'; +import { setSsoUsers, updateAllowedOrigins, updateUserConfig } from '@app/store/modules/config.js'; import { mergeSettingSlices } from '@app/unraid-api/types/json-forms.js'; import { csvStringToArray } from '@app/utils.js'; @@ -50,11 +50,12 @@ export class ConnectSettingsService { async getCurrentSettings(): Promise { const { getters } = await import('@app/store/index.js'); - const { local, api } = getters.config(); + const { local, api, remote } = getters.config(); return { ...(await this.dynamicRemoteAccessSettings()), sandbox: local.sandbox === 'yes', extraOrigins: csvStringToArray(api.extraOrigins), + ssoUserIds: csvStringToArray(remote.ssoSubIds), }; } @@ -63,7 +64,7 @@ export class ConnectSettingsService { * @param settings - The settings to sync * @returns true if a restart is required, false otherwise */ - async syncSettings(settings: Partial) { + async syncSettings(settings: Partial): Promise { let restartRequired = false; const { getters } = await import('@app/store/index.js'); const { nginx } = getters.emhttp(); @@ -86,13 +87,15 @@ export class ConnectSettingsService { port: settings.port, }); } - if (settings.extraOrigins) { await this.updateAllowedOrigins(settings.extraOrigins); } if (typeof settings.sandbox === 'boolean') { restartRequired ||= await this.setSandboxMode(settings.sandbox); } + if (settings.ssoUserIds) { + restartRequired ||= await this.updateSSOUsers(settings.ssoUserIds); + } const { writeConfigSync } = await import('@app/store/sync/config-disk-sync.js'); writeConfigSync('flash'); return restartRequired; @@ -117,6 +120,32 @@ export class ConnectSettingsService { return true; } + /** + * Updates the SSO users and returns true if a restart is required + * @param userIds - The list of SSO user IDs + * @returns true if a restart is required, false otherwise + */ + private async updateSSOUsers(userIds: string[]): Promise { + const { ssoUserIds } = await this.getCurrentSettings(); + const currentUserSet = new Set(ssoUserIds); + const newUserSet = new Set(userIds); + if (newUserSet.symmetricDifference(currentUserSet).size === 0) { + // there's no change, so no need to update + return false; + } + // make sure we aren't adding invalid user ids + const uuidRegex = + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + const invalidUserIds = userIds.filter((id) => !uuidRegex.test(id)); + if (invalidUserIds.length > 0) { + throw new GraphQLError(`Invalid SSO user ID's: ${invalidUserIds.join(', ')}`); + } + const { store } = await import('@app/store/index.js'); + store.dispatch(setSsoUsers(userIds)); + // request a restart if we're there were no sso users before + return currentUserSet.size === 0; + } + private async updateRemoteAccess(input: SetupRemoteAccessInput): Promise { const { store } = await import('@app/store/index.js'); await store.dispatch(setupRemoteAccessThunk(input)).unwrap(); @@ -151,6 +180,7 @@ export class ConnectSettingsService { await this.remoteAccessSlice(), await this.sandboxSlice(), this.flashBackupSlice(), + this.ssoUsersSlice(), // Because CORS is effectively disabled, this setting is no longer necessary // keeping it here for in case it needs to be re-enabled // @@ -344,4 +374,32 @@ export class ConnectSettingsService { ], }; } + + /** + * Extra origins settings slice + */ + ssoUsersSlice(): SettingSlice { + return { + properties: { + ssoUserIds: { + type: 'array', + items: { + type: 'string', + }, + title: 'Unraid API SSO Users', + description: `Provide a list of Unique Unraid Account ID's. Find yours at account.unraid.net/settings`, + }, + }, + elements: [ + { + type: 'Control', + scope: '#/properties/ssoUserIds', + options: { + inputType: 'text', + placeholder: 'UUID', + }, + }, + ], + }; + } } diff --git a/api/src/unraid-api/graph/connect/connect.resolver.ts b/api/src/unraid-api/graph/connect/connect.resolver.ts index 76a55b897..7eb001c6f 100644 --- a/api/src/unraid-api/graph/connect/connect.resolver.ts +++ b/api/src/unraid-api/graph/connect/connect.resolver.ts @@ -69,11 +69,11 @@ export class ConnectResolver implements ConnectResolvers { const restartRequired = await this.connectSettingsService.syncSettings(settings); const currentSettings = await this.connectSettingsService.getCurrentSettings(); if (restartRequired) { - const restartDelayMs = 3_000; setTimeout(async () => { + // Send restart out of band to avoid blocking the return of this resolver this.logger.log('Restarting API'); await this.connectService.restartApi(); - }, restartDelayMs); + }, 300); } return currentSettings; } diff --git a/unraid-ui/src/forms/StringArrayField.vue b/unraid-ui/src/forms/StringArrayField.vue index 594445dcb..aac3bdb46 100644 --- a/unraid-ui/src/forms/StringArrayField.vue +++ b/unraid-ui/src/forms/StringArrayField.vue @@ -44,7 +44,7 @@ const placeholder = computed(() => control.value.uischema?.options?.placeholder