Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions api/src/graphql/generated/api/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,8 @@ export function ApiSettingsInputSchema(): z.ZodObject<Properties<ApiSettingsInpu
extraOrigins: z.array(z.string()).nullish(),
forwardType: WAN_FORWARD_TYPESchema.nullish(),
port: z.number().nullish(),
sandbox: z.boolean().nullish()
sandbox: z.boolean().nullish(),
ssoUserIds: z.array(z.string()).nullish()
})
}

Expand Down Expand Up @@ -362,7 +363,8 @@ export function ConnectSettingsValuesSchema(): z.ZodObject<Properties<ConnectSet
extraOrigins: z.array(z.string()),
forwardType: WAN_FORWARD_TYPESchema.nullish(),
port: z.number().nullish(),
sandbox: z.boolean()
sandbox: z.boolean(),
ssoUserIds: z.array(z.string())
})
}

Expand Down
5 changes: 5 additions & 0 deletions api/src/graphql/generated/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ export type ApiSettingsInput = {
* If false, the GraphQL sandbox will be disabled and only the production API will be available.
*/
sandbox?: InputMaybe<Scalars['Boolean']['input']>;
/** A list of Unique Unraid Account ID's. */
ssoUserIds?: InputMaybe<Array<Scalars['String']['input']>>;
};

export type ArrayType = Node & {
Expand Down Expand Up @@ -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<Scalars['String']['output']>;
};

export type ConnectSignInInput = {
Expand Down Expand Up @@ -2373,6 +2377,7 @@ export type ConnectSettingsValuesResolvers<ContextType = Context, ParentType ext
forwardType?: Resolver<Maybe<ResolversTypes['WAN_FORWARD_TYPE']>, ParentType, ContextType>;
port?: Resolver<Maybe<ResolversTypes['Port']>, ParentType, ContextType>;
sandbox?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
ssoUserIds?: Resolver<Array<ResolversTypes['String']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
}>;

Expand Down
48 changes: 27 additions & 21 deletions api/src/graphql/schema/types/connect/connect.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,6 @@ input SetupRemoteAccessInput {
port: Port
}



input EnableDynamicRemoteAccessInput {
url: AccessUrlInput!
enabled: Boolean!
Expand All @@ -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 {
Expand Down Expand Up @@ -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!
}
}
5 changes: 5 additions & 0 deletions api/src/store/modules/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,9 @@ export const config = createSlice({
stateAsArray.push(action.payload);
state.remote.ssoSubIds = stateAsArray.join(',');
},
setSsoUsers(state, action: PayloadAction<string[]>) {
state.remote.ssoSubIds = action.payload.filter((id) => id).join(',');
},
removeSsoUser(state, action: PayloadAction<string | null>) {
if (action.payload === null) {
state.remote.ssoSubIds = '';
Expand Down Expand Up @@ -309,6 +312,7 @@ const { actions, reducer } = config;

export const {
addSsoUser,
setSsoUsers,
updateUserConfig,
updateAccessTokens,
updateAllowedOrigins,
Expand All @@ -324,6 +328,7 @@ export const {
*/
export const configUpdateActionsFlash = isAnyOf(
addSsoUser,
setSsoUsers,
updateUserConfig,
updateAccessTokens,
updateAllowedOrigins,
Expand Down
66 changes: 62 additions & 4 deletions api/src/unraid-api/graph/connect/connect-settings.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -50,11 +50,12 @@ export class ConnectSettingsService {

async getCurrentSettings(): Promise<ConnectSettingsValues> {
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),
};
}

Expand All @@ -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<ApiSettingsInput>) {
async syncSettings(settings: Partial<ApiSettingsInput>): Promise<boolean> {
let restartRequired = false;
const { getters } = await import('@app/store/index.js');
const { nginx } = getters.emhttp();
Expand All @@ -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;
Expand All @@ -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<boolean> {
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;
}
Comment on lines +128 to +147
Copy link
Contributor

@coderabbitai coderabbitai bot Apr 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

New updateSSOUsers method with validation

The method properly validates UUID format and detects changes between current and new user sets.

The symmetricDifference method used on line 132 isn't a standard Set method in JavaScript/TypeScript. Please verify if this is provided by a utility library or needs to be implemented:


🏁 Script executed:

#!/bin/bash
# Check if Set.prototype.symmetricDifference exists or is imported from a utility library
rg -F "symmetricDifference" --type ts

Length of output: 171


Action Required: Implement or Import symmetricDifference Method

The current implementation uses newUserSet.symmetricDifference(currentUserSet) on line 132, but JavaScript/TypeScript’s Set does not provide a built-in symmetricDifference method. There’s no indication from the repository that this method is defined or imported from a utility library. To resolve this:

  • Verify if a custom implementation was intended: If the method is defined elsewhere (perhaps in a shared utilities file), please add the appropriate import.
  • Implementation suggestion: If it’s missing, consider implementing the functionality manually or adding a helper function to compute the symmetric difference between two sets.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pujitm this is sick - didn't realize how far the web set had come, I've been dealing with the node one which is pretty worthless for any operations like this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!


private async updateRemoteAccess(input: SetupRemoteAccessInput): Promise<boolean> {
const { store } = await import('@app/store/index.js');
await store.dispatch(setupRemoteAccessThunk(input)).unwrap();
Expand Down Expand Up @@ -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
//
Expand Down Expand Up @@ -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 <a href="https://account.unraid.net/settings" target="_blank">account.unraid.net/settings</a>`,
},
},
elements: [
{
type: 'Control',
scope: '#/properties/ssoUserIds',
options: {
inputType: 'text',
placeholder: 'UUID',
},
},
],
};
}
}
4 changes: 2 additions & 2 deletions api/src/unraid-api/graph/connect/connect.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion unraid-ui/src/forms/StringArrayField.vue
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ const placeholder = computed(() => control.value.uischema?.options?.placeholder
<template>
<ControlLayout v-if="control.visible" :label="control.label" :errors="control.errors">
<div class="space-y-4">
<p v-if="control.description">{{ control.description }}</p>
<p v-if="control.description" v-html="control.description" />
<div v-for="(item, index) in items" :key="index" class="flex gap-2">
<Input
:type="inputType"
Expand Down
2 changes: 2 additions & 0 deletions web/components/ConnectSettings/graphql/settings.query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const getConnectSettingsForm = graphql(/* GraphQL */ `
accessType
forwardType
port
ssoUserIds
}
}
}
Expand All @@ -28,6 +29,7 @@ export const updateConnectSettings = graphql(/* GraphQL */ `
accessType
forwardType
port
ssoUserIds
}
}
`);
12 changes: 6 additions & 6 deletions web/composables/gql/gql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-
* Learn more about it here: https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#reducing-bundle-size
*/
type Documents = {
"\n query GetConnectSettingsForm {\n connect {\n id\n settings {\n id\n dataSchema\n uiSchema\n values {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n }\n }\n }\n }\n": typeof types.GetConnectSettingsFormDocument,
"\n mutation UpdateConnectSettings($input: ApiSettingsInput!) {\n updateApiSettings(input: $input) {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n }\n }\n": typeof types.UpdateConnectSettingsDocument,
"\n query GetConnectSettingsForm {\n connect {\n id\n settings {\n id\n dataSchema\n uiSchema\n values {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n ssoUserIds\n }\n }\n }\n }\n": typeof types.GetConnectSettingsFormDocument,
"\n mutation UpdateConnectSettings($input: ApiSettingsInput!) {\n updateApiSettings(input: $input) {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n ssoUserIds\n }\n }\n": typeof types.UpdateConnectSettingsDocument,
"\n query LogFiles {\n logFiles {\n name\n path\n size\n modifiedAt\n }\n }\n": typeof types.LogFilesDocument,
"\n query LogFileContent($path: String!, $lines: Int, $startLine: Int) {\n logFile(path: $path, lines: $lines, startLine: $startLine) {\n path\n content\n totalLines\n startLine\n }\n }\n": typeof types.LogFileContentDocument,
"\n subscription LogFileSubscription($path: String!) {\n logFile(path: $path) {\n path\n content\n totalLines\n }\n }\n": typeof types.LogFileSubscriptionDocument,
Expand All @@ -40,8 +40,8 @@ type Documents = {
"\n mutation setupRemoteAccess($input: SetupRemoteAccessInput!) {\n setupRemoteAccess(input: $input)\n }\n": typeof types.setupRemoteAccessDocument,
};
const documents: Documents = {
"\n query GetConnectSettingsForm {\n connect {\n id\n settings {\n id\n dataSchema\n uiSchema\n values {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n }\n }\n }\n }\n": types.GetConnectSettingsFormDocument,
"\n mutation UpdateConnectSettings($input: ApiSettingsInput!) {\n updateApiSettings(input: $input) {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n }\n }\n": types.UpdateConnectSettingsDocument,
"\n query GetConnectSettingsForm {\n connect {\n id\n settings {\n id\n dataSchema\n uiSchema\n values {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n ssoUserIds\n }\n }\n }\n }\n": types.GetConnectSettingsFormDocument,
"\n mutation UpdateConnectSettings($input: ApiSettingsInput!) {\n updateApiSettings(input: $input) {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n ssoUserIds\n }\n }\n": types.UpdateConnectSettingsDocument,
"\n query LogFiles {\n logFiles {\n name\n path\n size\n modifiedAt\n }\n }\n": types.LogFilesDocument,
"\n query LogFileContent($path: String!, $lines: Int, $startLine: Int) {\n logFile(path: $path, lines: $lines, startLine: $startLine) {\n path\n content\n totalLines\n startLine\n }\n }\n": types.LogFileContentDocument,
"\n subscription LogFileSubscription($path: String!) {\n logFile(path: $path) {\n path\n content\n totalLines\n }\n }\n": types.LogFileSubscriptionDocument,
Expand Down Expand Up @@ -83,11 +83,11 @@ export function graphql(source: string): unknown;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query GetConnectSettingsForm {\n connect {\n id\n settings {\n id\n dataSchema\n uiSchema\n values {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n }\n }\n }\n }\n"): (typeof documents)["\n query GetConnectSettingsForm {\n connect {\n id\n settings {\n id\n dataSchema\n uiSchema\n values {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n }\n }\n }\n }\n"];
export function graphql(source: "\n query GetConnectSettingsForm {\n connect {\n id\n settings {\n id\n dataSchema\n uiSchema\n values {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n ssoUserIds\n }\n }\n }\n }\n"): (typeof documents)["\n query GetConnectSettingsForm {\n connect {\n id\n settings {\n id\n dataSchema\n uiSchema\n values {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n ssoUserIds\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation UpdateConnectSettings($input: ApiSettingsInput!) {\n updateApiSettings(input: $input) {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n }\n }\n"): (typeof documents)["\n mutation UpdateConnectSettings($input: ApiSettingsInput!) {\n updateApiSettings(input: $input) {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n }\n }\n"];
export function graphql(source: "\n mutation UpdateConnectSettings($input: ApiSettingsInput!) {\n updateApiSettings(input: $input) {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n ssoUserIds\n }\n }\n"): (typeof documents)["\n mutation UpdateConnectSettings($input: ApiSettingsInput!) {\n updateApiSettings(input: $input) {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n ssoUserIds\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
Expand Down
Loading
Loading