diff --git a/api/dev/configs/api.json b/api/dev/configs/api.json index 11b6dcf64..6664e3ecb 100644 --- a/api/dev/configs/api.json +++ b/api/dev/configs/api.json @@ -6,5 +6,5 @@ ], "sandbox": true, "ssoSubIds": [], - "plugins": [] + "plugins": ["unraid-api-plugin-connect"] } \ No newline at end of file diff --git a/api/dev/configs/connect.json b/api/dev/configs/connect.json index 157a8984b..e69de29bb 100644 --- a/api/dev/configs/connect.json +++ b/api/dev/configs/connect.json @@ -1,16 +0,0 @@ -{ - "wanaccess": false, - "wanport": 0, - "upnpEnabled": false, - "apikey": "", - "localApiKey": "", - "email": "", - "username": "", - "avatar": "", - "regWizTime": "", - "accesstoken": "", - "idtoken": "", - "refreshtoken": "", - "dynamicRemoteAccessType": "DISABLED", - "ssoSubIds": [] -} \ No newline at end of file diff --git a/api/generated-schema-new.graphql b/api/generated-schema-new.graphql deleted file mode 100644 index 8daa7f161..000000000 --- a/api/generated-schema-new.graphql +++ /dev/null @@ -1,1563 +0,0 @@ -# ------------------------------------------------------ -# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY) -# ------------------------------------------------------ - -type ApiKeyResponse { - valid: Boolean! - error: String -} - -type MinigraphqlResponse { - status: MinigraphStatus! - timeout: Int - error: String -} - -enum MinigraphStatus { - PRE_INIT - CONNECTING - CONNECTED - PING_FAILURE - ERROR_RETRYING -} - -type CloudResponse { - status: String! - ip: String - error: String -} - -type RelayResponse { - status: String! - timeout: String - error: String -} - -type Cloud { - error: String - apiKey: ApiKeyResponse! - relay: RelayResponse - minigraphql: MinigraphqlResponse! - cloud: CloudResponse! - allowedOrigins: [String!]! -} - -type Capacity { - """Free capacity""" - free: String! - - """Used capacity""" - used: String! - - """Total capacity""" - total: String! -} - -type ArrayCapacity { - """Capacity in kilobytes""" - kilobytes: Capacity! - - """Capacity in number of disks""" - disks: Capacity! -} - -type ArrayDisk implements Node { - """Disk identifier, only set for present disks on the system""" - id: ID! - - """ - Array slot number. Parity1 is always 0 and Parity2 is always 29. Array slots will be 1 - 28. Cache slots are 30 - 53. Flash is 54. - """ - idx: Int! - name: String - device: String - - """(KB) Disk Size total""" - size: Long - status: ArrayDiskStatus - - """Is the disk a HDD or SSD.""" - rotational: Boolean - - """Disk temp - will be NaN if array is not started or DISK_NP""" - temp: Int - - """ - Count of I/O read requests sent to the device I/O drivers. These statistics may be cleared at any time. - """ - numReads: Long - - """ - Count of I/O writes requests sent to the device I/O drivers. These statistics may be cleared at any time. - """ - numWrites: Long - - """ - Number of unrecoverable errors reported by the device I/O drivers. Missing data due to unrecoverable array read errors is filled in on-the-fly using parity reconstruct (and we attempt to write this data back to the sector(s) which failed). Any unrecoverable write error results in disabling the disk. - """ - numErrors: Long - - """(KB) Total Size of the FS (Not present on Parity type drive)""" - fsSize: Long - - """(KB) Free Size on the FS (Not present on Parity type drive)""" - fsFree: Long - - """(KB) Used Size on the FS (Not present on Parity type drive)""" - fsUsed: Long - exportable: Boolean - - """Type of Disk - used to differentiate Cache / Flash / Array / Parity""" - type: ArrayDiskType! - - """(%) Disk space left to warn""" - warning: Int - - """(%) Disk space left for critical""" - critical: Int - - """File system type for the disk""" - fsType: String - - """User comment on disk""" - comment: String - - """File format (ex MBR: 4KiB-aligned)""" - format: String - - """ata | nvme | usb | (others)""" - transport: String - color: ArrayDiskFsColor -} - -interface Node { - id: ID! -} - -"""The `Long` scalar type represents 52-bit integers""" -scalar Long - -enum ArrayDiskStatus { - DISK_NP - DISK_OK - DISK_NP_MISSING - DISK_INVALID - DISK_WRONG - DISK_DSBL - DISK_NP_DSBL - DISK_DSBL_NEW - DISK_NEW -} - -enum ArrayDiskType { - DATA - PARITY - FLASH - CACHE -} - -enum ArrayDiskFsColor { - GREEN_ON - GREEN_BLINK - BLUE_ON - BLUE_BLINK - YELLOW_ON - YELLOW_BLINK - RED_ON - RED_OFF - GREY_OFF -} - -type UnraidArray implements Node { - id: ID! - - """Array state before this query/mutation""" - previousState: ArrayState - - """Array state after this query/mutation""" - pendingState: ArrayPendingState - - """Current array state""" - state: ArrayState! - - """Current array capacity""" - capacity: ArrayCapacity! - - """Current boot disk""" - boot: ArrayDisk - - """Parity disks in the current array""" - parities: [ArrayDisk!]! - - """Data disks in the current array""" - disks: [ArrayDisk!]! - - """Caches in the current array""" - caches: [ArrayDisk!]! -} - -enum ArrayState { - STARTED - STOPPED - NEW_ARRAY - RECON_DISK - DISABLE_DISK - SWAP_DSBL - INVALID_EXPANSION - PARITY_NOT_BIGGEST - TOO_MANY_MISSING_DISKS - NEW_DISK_TOO_SMALL - NO_DATA_DISKS -} - -enum ArrayPendingState { - STARTING - STOPPING - NO_DATA_DISKS - TOO_MANY_MISSING_DISKS -} - -type Share implements Node { - id: ID! - - """Display name""" - name: String - - """(KB) Free space""" - free: Long - - """(KB) Used Size""" - used: Long - - """(KB) Total size""" - size: Long - - """Disks that are included in this share""" - include: [String!] - - """Disks that are excluded from this share""" - exclude: [String!] - - """Is this share cached""" - cache: Boolean - - """Original name""" - nameOrig: String - - """User comment""" - comment: String - - """Allocator""" - allocator: String - - """Split level""" - splitLevel: String - - """Floor""" - floor: String - - """COW""" - cow: String - - """Color""" - color: String - - """LUKS status""" - luksStatus: String -} - -type RemoteAccess { - """The type of WAN access used for Remote Access""" - accessType: WAN_ACCESS_TYPE! - - """The type of port forwarding used for Remote Access""" - forwardType: WAN_FORWARD_TYPE - - """The port used for Remote Access""" - port: Int -} - -enum WAN_ACCESS_TYPE { - DYNAMIC - ALWAYS - DISABLED -} - -enum WAN_FORWARD_TYPE { - UPNP - STATIC -} - -type DynamicRemoteAccessStatus { - """The type of dynamic remote access that is enabled""" - enabledType: DynamicRemoteAccessType! - - """The type of dynamic remote access that is currently running""" - runningType: DynamicRemoteAccessType! - - """Any error message associated with the dynamic remote access""" - error: String -} - -enum DynamicRemoteAccessType { - STATIC - UPNP - DISABLED -} - -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. - """ - sandbox: Boolean! - - """A list of origins allowed to interact with the API""" - extraOrigins: [String!]! - - """The type of WAN access used for Remote Access""" - accessType: WAN_ACCESS_TYPE! - - """The type of port forwarding used for Remote Access""" - forwardType: WAN_FORWARD_TYPE - - """The port used for Remote Access""" - port: Int - - """A list of Unique Unraid Account ID's""" - ssoUserIds: [String!]! -} - -type ConnectSettings implements Node { - """The unique identifier for the Connect settings""" - id: ID! - - """The data schema for the Connect settings""" - dataSchema: JSON! - - """The UI schema for the Connect settings""" - uiSchema: JSON! - - """The values for the Connect settings""" - values: ConnectSettingsValues! -} - -""" -The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). -""" -scalar JSON @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf") - -type Connect implements Node { - """The unique identifier for the Connect instance""" - id: ID! - - """The status of dynamic remote access""" - dynamicRemoteAccess: DynamicRemoteAccessStatus! - - """The settings for the Connect instance""" - settings: ConnectSettings! -} - -type AccessUrl { - type: URL_TYPE! - name: String - ipv4: URL - ipv6: URL -} - -enum URL_TYPE { - LAN - WIREGUARD - WAN - MDNS - OTHER - DEFAULT -} - -""" -A field whose value conforms to the standard URL format as specified in RFC3986: https://www.ietf.org/rfc/rfc3986.txt. -""" -scalar URL - -type Network implements Node { - id: ID! - accessUrls: [AccessUrl!] -} - -type ProfileModel { - userId: ID - username: String! - url: String! - avatar: String! -} - -type Server { - owner: ProfileModel! - guid: String! - apikey: String! - name: String! - status: ServerStatus! - wanip: String! - lanip: String! - localurl: String! - remoteurl: String! -} - -enum ServerStatus { - ONLINE - OFFLINE - NEVER_CONNECTED -} - -type DiskPartition { - """The name of the partition""" - name: String! - - """The filesystem type of the partition""" - fsType: DiskFsType! - - """The size of the partition in bytes""" - size: Float! -} - -"""The type of filesystem on the disk partition""" -enum DiskFsType { - XFS - BTRFS - VFAT - ZFS - EXT4 - NTFS -} - -type Disk { - """The unique identifier of the disk""" - id: String! - - """The device path of the disk (e.g. /dev/sdb)""" - device: String! - - """The type of disk (e.g. SSD, HDD)""" - type: String! - - """The model name of the disk""" - name: String! - - """The manufacturer of the disk""" - vendor: String! - - """The total size of the disk in bytes""" - size: Float! - - """The number of bytes per sector""" - bytesPerSector: Float! - - """The total number of cylinders on the disk""" - totalCylinders: Float! - - """The total number of heads on the disk""" - totalHeads: Float! - - """The total number of sectors on the disk""" - totalSectors: Float! - - """The total number of tracks on the disk""" - totalTracks: Float! - - """The number of tracks per cylinder""" - tracksPerCylinder: Float! - - """The number of sectors per track""" - sectorsPerTrack: Float! - - """The firmware revision of the disk""" - firmwareRevision: String! - - """The serial number of the disk""" - serialNum: String! - - """The interface type of the disk""" - interfaceType: DiskInterfaceType! - - """The SMART status of the disk""" - smartStatus: DiskSmartStatus! - - """The current temperature of the disk in Celsius""" - temperature: Float - - """The partitions on the disk""" - partitions: [DiskPartition!]! -} - -"""The type of interface the disk uses to connect to the system""" -enum DiskInterfaceType { - SAS - SATA - USB - PCIE - UNKNOWN -} - -""" -The SMART (Self-Monitoring, Analysis and Reporting Technology) status of the disk -""" -enum DiskSmartStatus { - OK - UNKNOWN -} - -type KeyFile { - location: String - contents: String -} - -type Registration { - guid: ID - type: registrationType - keyFile: KeyFile - state: RegistrationState - expiration: String - updateExpiration: String -} - -enum registrationType { - BASIC - PLUS - PRO - STARTER - UNLEASHED - LIFETIME - INVALID - TRIAL -} - -enum RegistrationState { - TRIAL - BASIC - PLUS - PRO - STARTER - UNLEASHED - LIFETIME - EEXPIRED - EGUID - EGUID1 - ETRIAL - ENOKEYFILE - ENOKEYFILE1 - ENOKEYFILE2 - ENOFLASH - ENOFLASH1 - ENOFLASH2 - ENOFLASH3 - ENOFLASH4 - ENOFLASH5 - ENOFLASH6 - ENOFLASH7 - EBLACKLISTED - EBLACKLISTED1 - EBLACKLISTED2 - ENOCONN -} - -type Vars implements Node { - id: ID! - - """Unraid version""" - version: String - maxArraysz: Int - maxCachesz: Int - - """Machine hostname""" - name: String - timeZone: String - comment: String - security: String - workgroup: String - domain: String - domainShort: String - hideDotFiles: Boolean - localMaster: Boolean - enableFruit: String - - """Should a NTP server be used for time sync?""" - useNtp: Boolean - - """NTP Server 1""" - ntpServer1: String - - """NTP Server 2""" - ntpServer2: String - - """NTP Server 3""" - ntpServer3: String - - """NTP Server 4""" - ntpServer4: String - domainLogin: String - sysModel: String - sysArraySlots: Int - sysCacheSlots: Int - sysFlashSlots: Int - useSsl: Boolean - - """Port for the webui via HTTP""" - port: Int - - """Port for the webui via HTTPS""" - portssl: Int - localTld: String - bindMgt: Boolean - - """Should telnet be enabled?""" - useTelnet: Boolean - porttelnet: Int - useSsh: Boolean - portssh: Int - startPage: String - startArray: Boolean - spindownDelay: String - queueDepth: String - spinupGroups: Boolean - defaultFormat: String - defaultFsType: String - shutdownTimeout: Int - luksKeyfile: String - pollAttributes: String - pollAttributesDefault: String - pollAttributesStatus: String - nrRequests: Int - nrRequestsDefault: Int - nrRequestsStatus: String - mdNumStripes: Int - mdNumStripesDefault: Int - mdNumStripesStatus: String - mdSyncWindow: Int - mdSyncWindowDefault: Int - mdSyncWindowStatus: String - mdSyncThresh: Int - mdSyncThreshDefault: Int - mdSyncThreshStatus: String - mdWriteMethod: Int - mdWriteMethodDefault: String - mdWriteMethodStatus: String - shareDisk: String - shareUser: String - shareUserInclude: String - shareUserExclude: String - shareSmbEnabled: Boolean - shareNfsEnabled: Boolean - shareAfpEnabled: Boolean - shareInitialOwner: String - shareInitialGroup: String - shareCacheEnabled: Boolean - shareCacheFloor: String - shareMoverSchedule: String - shareMoverLogging: Boolean - fuseRemember: String - fuseRememberDefault: String - fuseRememberStatus: String - fuseDirectio: String - fuseDirectioDefault: String - fuseDirectioStatus: String - shareAvahiEnabled: Boolean - shareAvahiSmbName: String - shareAvahiSmbModel: String - shareAvahiAfpName: String - shareAvahiAfpModel: String - safeMode: Boolean - startMode: String - configValid: Boolean - configError: ConfigErrorState - joinStatus: String - deviceCount: Int - flashGuid: String - flashProduct: String - flashVendor: String - regCheck: String - regFile: String - regGuid: String - regTy: registrationType - regState: RegistrationState - - """Registration owner""" - regTo: String - regTm: String - regTm2: String - regGen: String - sbName: String - sbVersion: String - sbUpdated: String - sbEvents: Int - sbState: String - sbClean: Boolean - sbSynced: Int - sbSyncErrs: Int - sbSynced2: Int - sbSyncExit: String - sbNumDisks: Int - mdColor: String - mdNumDisks: Int - mdNumDisabled: Int - mdNumInvalid: Int - mdNumMissing: Int - mdNumNew: Int - mdNumErased: Int - mdResync: Int - mdResyncCorr: String - mdResyncPos: String - mdResyncDb: String - mdResyncDt: String - mdResyncAction: String - mdResyncSize: Int - mdState: String - mdVersion: String - cacheNumDevices: Int - cacheSbNumDisks: Int - fsState: String - - """Human friendly string of array events happening""" - fsProgress: String - - """ - Percentage from 0 - 100 while upgrading a disk or swapping parity drives - """ - fsCopyPrcnt: Int - fsNumMounted: Int - fsNumUnmountable: Int - fsUnmountableMask: String - - """Total amount of user shares""" - shareCount: Int - - """Total amount shares with SMB enabled""" - shareSmbCount: Int - - """Total amount shares with NFS enabled""" - shareNfsCount: Int - - """Total amount shares with AFP enabled""" - shareAfpCount: Int - shareMoverActive: Boolean - csrfToken: String -} - -"""Possible error states for configuration""" -enum ConfigErrorState { - UNKNOWN_ERROR - INELIGIBLE - INVALID - NO_KEY_SERVER - WITHDRAWN -} - -type Permission { - resource: Resource! - actions: [String!]! -} - -"""Available resources for permissions""" -enum Resource { - API_KEY - ARRAY - CLOUD - CONFIG - CONNECT - CONNECT__REMOTE_ACCESS - CUSTOMIZATIONS - DASHBOARD - DISK - DISPLAY - DOCKER - FLASH - INFO - LOGS - ME - NETWORK - NOTIFICATIONS - ONLINE - OS - OWNER - PERMISSION - REGISTRATION - SERVERS - SERVICES - SHARE - VARS - VMS - WELCOME -} - -type ApiKey { - id: ID! - name: String! - description: String - roles: [Role!]! - createdAt: String! - permissions: [Permission!]! -} - -"""Available roles for API keys and users""" -enum Role { - ADMIN - CONNECT - GUEST -} - -type ApiKeyWithSecret { - id: ID! - name: String! - description: String - roles: [Role!]! - createdAt: String! - permissions: [Permission!]! - key: String! -} - -type ArrayMutations { - """Set array state""" - setState(input: ArrayStateInput!): UnraidArray! - - """Add new disk to array""" - addDiskToArray(input: ArrayDiskInput!): UnraidArray! - - """ - Remove existing disk from array. NOTE: The array must be stopped before running this otherwise it'll throw an error. - """ - removeDiskFromArray(input: ArrayDiskInput!): UnraidArray! - - """Mount a disk in the array""" - mountArrayDisk(id: String!): ArrayDisk! - - """Unmount a disk from the array""" - unmountArrayDisk(id: String!): ArrayDisk! - - """Clear statistics for a disk in the array""" - clearArrayDiskStatistics(id: String!): Boolean! -} - -input ArrayStateInput { - """Array state""" - desiredState: ArrayStateInputState! -} - -enum ArrayStateInputState { - START - STOP -} - -input ArrayDiskInput { - """Disk ID""" - id: ID! - - """The slot for the disk""" - slot: Int -} - -type DockerMutations { - """Start a container""" - start(id: String!): DockerContainer! - - """Stop a container""" - stop(id: String!): DockerContainer! -} - -type ParityCheck { - """Date of the parity check""" - date: DateTime - - """Duration of the parity check in seconds""" - duration: Int - - """Speed of the parity check, in MB/s""" - speed: String - - """Status of the parity check""" - status: String - - """Number of errors during the parity check""" - errors: Int - - """Progress percentage of the parity check""" - progress: Int - - """Whether corrections are being written to parity""" - correcting: Boolean - - """Whether the parity check is paused""" - paused: Boolean - - """Whether the parity check is running""" - running: Boolean -} - -""" -A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. -""" -scalar DateTime - -type Config implements Node { - id: ID! - valid: Boolean - error: String -} - -type InfoApps implements Node { - id: ID! - - """How many docker containers are installed""" - installed: Int! - - """How many docker containers are running""" - started: Int! -} - -type Baseboard implements Node { - id: ID! - manufacturer: String! - model: String - version: String - serial: String - assetTag: String -} - -type InfoCpu implements Node { - id: ID! - manufacturer: String! - brand: String! - vendor: String! - family: String! - model: String! - stepping: Int! - revision: String! - voltage: String - speed: Float! - speedmin: Float! - speedmax: Float! - threads: Int! - cores: Int! - processors: Int! - socket: String! - cache: JSON! - flags: [String!]! -} - -type Gpu implements Node { - id: ID! - type: String! - typeid: String! - vendorname: String! - productid: String! - blacklisted: Boolean! - class: String! -} - -type Pci implements Node { - id: ID! - type: String - typeid: String - vendorname: String - vendorid: String - productname: String - productid: String - blacklisted: String - class: String -} - -type Usb { - id: ID! - name: String -} - -type Devices implements Node { - id: ID! - gpu: [Gpu!]! - pci: [Pci!]! - usb: [Usb!]! -} - -type Case implements Node { - id: ID! - icon: String - url: String - error: String - base64: String -} - -type Display implements Node { - id: ID! - case: Case - date: String - number: String - scale: Boolean - tabs: Boolean - users: String - resize: Boolean - wwn: Boolean - total: Boolean - usage: Boolean - banner: String - dashapps: String - theme: Theme - text: Boolean - unit: Temperature - warning: Int - critical: Int - hot: Int - max: Int - locale: String -} - -"""Display theme""" -enum Theme { - white -} - -"""Temperature unit (Celsius or Fahrenheit)""" -enum Temperature { - C - F -} - -type MemoryLayout { - size: Int! - bank: String - type: String - clockSpeed: Int - formFactor: String - manufacturer: String - partNum: String - serialNum: String - voltageConfigured: Int - voltageMin: Int - voltageMax: Int -} - -type InfoMemory implements Node { - id: ID! - max: Int! - total: Int! - free: Int! - used: Int! - active: Int! - available: Int! - buffcache: Int! - swaptotal: Int! - swapused: Int! - swapfree: Int! - layout: [MemoryLayout!]! -} - -type Os implements Node { - id: ID! - platform: String - distro: String - release: String - codename: String - kernel: String - arch: String - hostname: String - codepage: String - logofile: String - serial: String - build: String - uptime: String -} - -type System implements Node { - id: ID! - manufacturer: String - model: String - version: String - serial: String - uuid: String - sku: String -} - -type Versions implements Node { - id: ID! - kernel: String - openssl: String - systemOpenssl: String - systemOpensslLib: String - node: String - v8: String - npm: String - yarn: String - pm2: String - gulp: String - grunt: String - git: String - tsc: String - mysql: String - redis: String - mongodb: String - apache: String - nginx: String - php: String - docker: String - postfix: String - postgresql: String - perl: String - python: String - gcc: String - unraid: String -} - -type Info implements Node { - id: ID! - - """Count of docker containers""" - apps: InfoApps! - baseboard: Baseboard! - cpu: InfoCpu! - devices: Devices! - display: Display! - - """Machine ID""" - machineId: ID - memory: InfoMemory! - os: Os! - system: System! - time: DateTime! - versions: Versions! -} - -type ContainerPort { - ip: String - privatePort: Int! - publicPort: Int! - type: ContainerPortType! -} - -enum ContainerPortType { - TCP - UDP -} - -type ContainerHostConfig { - networkMode: String! -} - -type DockerContainer { - id: ID! - names: [String!]! - image: String! - imageId: String! - command: String! - created: Int! - ports: [ContainerPort!]! - - """Total size of all the files in the container""" - sizeRootFs: Int - labels: JSONObject - state: ContainerState! - status: String! - hostConfig: ContainerHostConfig - networkSettings: JSONObject - mounts: [JSONObject!] - autoStart: Boolean! -} - -""" -The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). -""" -scalar JSONObject - -enum ContainerState { - RUNNING - EXITED -} - -type DockerNetwork { - name: String! - id: ID! - created: String! - scope: String! - driver: String! - enableIPv6: Boolean! - ipam: JSONObject! - internal: Boolean! - attachable: Boolean! - ingress: Boolean! - configFrom: JSONObject! - configOnly: Boolean! - containers: JSONObject! - options: JSONObject! - labels: JSONObject! -} - -type Docker implements Node { - id: ID! - containers: [DockerContainer!]! - networks: [DockerNetwork!]! -} - -type Flash implements Node { - id: ID! - guid: String! - vendor: String! - product: String! -} - -type LogFile { - """Name of the log file""" - name: String! - - """Full path to the log file""" - path: String! - - """Size of the log file in bytes""" - size: Int! - - """Last modified timestamp""" - modifiedAt: DateTime! -} - -type LogFileContent { - """Path to the log file""" - path: String! - - """Content of the log file""" - content: String! - - """Total number of lines in the file""" - totalLines: Int! - - """Starting line number of the content (1-indexed)""" - startLine: Int -} - -type NotificationCounts { - info: Int! - warning: Int! - alert: Int! - total: Int! -} - -type NotificationOverview { - unread: NotificationCounts! - archive: NotificationCounts! -} - -type Notification { - id: ID! - - """Also known as 'event'""" - title: String! - subject: String! - description: String! - importance: NotificationImportance! - link: String - type: NotificationType! - - """ISO Timestamp for when the notification occurred""" - timestamp: String - formattedTimestamp: String -} - -enum NotificationImportance { - ALERT - INFO - WARNING -} - -enum NotificationType { - UNREAD - ARCHIVE -} - -type Notifications { - id: ID! - - """A cached overview of the notifications in the system & their severity.""" - overview: NotificationOverview! - list(filter: NotificationFilter!): [Notification!]! -} - -input NotificationFilter { - importance: NotificationImportance - type: NotificationType! - offset: Int! - limit: Int! -} - -type Owner { - username: String! - url: String! - avatar: String! -} - -type VmDomain { - uuid: ID! - - """A friendly name for the vm""" - name: String - - """Current domain vm state""" - state: VmState! -} - -"""The state of a virtual machine""" -enum VmState { - NOSTATE - RUNNING - IDLE - PAUSED - SHUTDOWN - SHUTOFF - CRASHED - PMSUSPENDED -} - -type Vms { - id: ID! - domains: [VmDomain!] -} - -type Uptime { - timestamp: String -} - -type Service implements Node { - id: ID! - name: String - online: Boolean - uptime: Uptime - version: String -} - -type UserAccount { - """A unique identifier for the user""" - id: ID! - - """The name of the user""" - name: String! - - """A description of the user""" - description: String! - - """The roles of the user""" - roles: [Role!]! - - """The permissions of the user""" - permissions: [Permission!] -} - -type Query { - apiKeys: [ApiKey!]! - apiKey(id: String!): ApiKey - cloud: Cloud! - config: Config! - display: Display! - flash: Flash! - info: Info! - logFiles: [LogFile!]! - logFile(path: String!, lines: Int, startLine: Int): LogFileContent! - me: UserAccount! - network: Network! - - """Get all notifications""" - notifications: Notifications! - online: Boolean! - owner: Owner! - registration: Registration - server: Server - servers: [Server!]! - services: [Service!]! - shares: [Share!]! - vars: Vars! - vms: Vms! - parityHistory: [ParityCheck!]! - array: UnraidArray! - connect: Connect! - remoteAccess: RemoteAccess! - extraAllowedOrigins: [String!]! - docker: Docker! - disks: [Disk!]! - disk(id: String!): Disk! -} - -type Mutation { - createApiKey(input: CreateApiKeyInput!): ApiKeyWithSecret! - addRoleForApiKey(input: AddRoleForApiKeyInput!): Boolean! - removeRoleFromApiKey(input: RemoveRoleFromApiKeyInput!): Boolean! - - """Creates a new notification record""" - createNotification(input: NotificationData!): Notification! - deleteNotification(id: String!, type: NotificationType!): NotificationOverview! - - """Deletes all archived notifications on server.""" - deleteArchivedNotifications: NotificationOverview! - - """Marks a notification as archived.""" - archiveNotification(id: String!): Notification! - archiveNotifications(ids: [String!]!): NotificationOverview! - archiveAll(importance: NotificationImportance): NotificationOverview! - - """Marks a notification as unread.""" - unreadNotification(id: String!): Notification! - unarchiveNotifications(ids: [String!]!): NotificationOverview! - unarchiveAll(importance: NotificationImportance): NotificationOverview! - - """Reads each notification to recompute & update the overview.""" - recalculateOverview: NotificationOverview! - array: ArrayMutations! - docker: DockerMutations! - - """Start a virtual machine""" - startVm(id: String!): Boolean! - - """Stop a virtual machine""" - stopVm(id: String!): Boolean! - - """Pause a virtual machine""" - pauseVm(id: String!): Boolean! - - """Resume a virtual machine""" - resumeVm(id: String!): Boolean! - - """Force stop a virtual machine""" - forceStopVm(id: String!): Boolean! - - """Reboot a virtual machine""" - rebootVm(id: String!): Boolean! - - """Reset a virtual machine""" - resetVm(id: String!): Boolean! - startParityCheck(correct: Boolean!): JSON! - pauseParityCheck: JSON! - resumeParityCheck: JSON! - cancelParityCheck: JSON! - updateApiSettings(input: ApiSettingsInput!): ConnectSettingsValues! - connectSignIn(input: ConnectSignInInput!): Boolean! - connectSignOut: Boolean! - setupRemoteAccess(input: SetupRemoteAccessInput!): Boolean! - setAdditionalAllowedOrigins(input: AllowedOriginInput!): [String!]! - enableDynamicRemoteAccess(input: EnableDynamicRemoteAccessInput!): Boolean! -} - -input CreateApiKeyInput { - name: String! - description: String - roles: [Role!] - permissions: [AddPermissionInput!] - - """ - This will replace the existing key if one already exists with the same name, otherwise returns the existing key - """ - overwrite: Boolean -} - -input AddPermissionInput { - resource: Resource! - actions: [String!]! -} - -input AddRoleForApiKeyInput { - apiKeyId: ID! - role: Role! -} - -input RemoveRoleFromApiKeyInput { - apiKeyId: ID! - role: Role! -} - -input NotificationData { - title: String! - subject: String! - description: String! - importance: NotificationImportance! - link: String -} - -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. - """ - sandbox: Boolean - - """A list of origins allowed to interact with the API""" - extraOrigins: [String!] - - """The type of WAN access to use for Remote Access""" - accessType: WAN_ACCESS_TYPE - - """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. - """ - port: Int - - """A list of Unique Unraid Account ID's""" - ssoUserIds: [String!] -} - -input ConnectSignInInput { - """The API key for authentication""" - apiKey: String! - - """The ID token for authentication""" - idToken: String - - """User information for the sign-in""" - userInfo: ConnectUserInfoInput - - """The access token for authentication""" - accessToken: String - - """The refresh token for authentication""" - refreshToken: String -} - -input ConnectUserInfoInput { - """The preferred username of the user""" - preferred_username: String! - - """The email address of the user""" - email: String! - - """The avatar URL of the user""" - avatar: String -} - -input SetupRemoteAccessInput { - """The type of WAN access to use for Remote Access""" - accessType: WAN_ACCESS_TYPE! - - """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. - """ - port: Int -} - -input AllowedOriginInput { - """A list of origins allowed to interact with the API""" - origins: [String!]! -} - -input EnableDynamicRemoteAccessInput { - """The URL for dynamic remote access""" - url: URL! - - """Whether to enable or disable dynamic remote access""" - enabled: Boolean! -} - -type Subscription { - displaySubscription: Display! - infoSubscription: Info! - logFile(path: String!): LogFileContent! - notificationAdded: Notification! - notificationsOverview: NotificationOverview! - ownerSubscription: Owner! - registrationSubscription: Registration! - serversSubscription: Server! - parityHistorySubscription: ParityCheck! - arraySubscription: UnraidArray! -} \ No newline at end of file diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index 9b8328f87..997ff6372 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -226,6 +226,27 @@ type Share implements Node { luksStatus: String } +type AccessUrl { + type: URL_TYPE! + name: String + ipv4: URL + ipv6: URL +} + +enum URL_TYPE { + LAN + WIREGUARD + WAN + MDNS + OTHER + DEFAULT +} + +""" +A field whose value conforms to the standard URL format as specified in RFC3986: https://www.ietf.org/rfc/rfc3986.txt. +""" +scalar URL + type DiskPartition { """The name of the partition""" name: String! @@ -756,6 +777,9 @@ type ApiKeyMutations { """Delete one or more API keys""" delete(input: DeleteApiKeyInput!): Boolean! + + """Update an API key""" + update(input: UpdateApiKeyInput!): ApiKeyWithSecret! } input CreateApiKeyInput { @@ -789,6 +813,14 @@ input DeleteApiKeyInput { ids: [PrefixedID!]! } +input UpdateApiKeyInput { + id: PrefixedID! + name: String + description: String + roles: [Role!] + permissions: [AddPermissionInput!] +} + """ Parity check related mutations, WIP, response types and functionaliy will change """ @@ -1458,6 +1490,139 @@ type Plugin { hasCliModule: Boolean } +type AccessUrlObject { + ipv4: String + ipv6: String + type: URL_TYPE! + name: String +} + +type RemoteAccess { + """The type of WAN access used for Remote Access""" + accessType: WAN_ACCESS_TYPE! + + """The type of port forwarding used for Remote Access""" + forwardType: WAN_FORWARD_TYPE + + """The port used for Remote Access""" + port: Int +} + +enum WAN_ACCESS_TYPE { + DYNAMIC + ALWAYS + DISABLED +} + +enum WAN_FORWARD_TYPE { + UPNP + STATIC +} + +type DynamicRemoteAccessStatus { + """The type of dynamic remote access that is enabled""" + enabledType: DynamicRemoteAccessType! + + """The type of dynamic remote access that is currently running""" + runningType: DynamicRemoteAccessType! + + """Any error message associated with the dynamic remote access""" + error: String +} + +enum DynamicRemoteAccessType { + STATIC + UPNP + DISABLED +} + +type ConnectSettingsValues { + """The type of WAN access used for Remote Access""" + accessType: WAN_ACCESS_TYPE! + + """The type of port forwarding used for Remote Access""" + forwardType: WAN_FORWARD_TYPE + + """The port used for Remote Access""" + port: Int +} + +type ConnectSettings implements Node { + id: PrefixedID! + + """The data schema for the Connect settings""" + dataSchema: JSON! + + """The UI schema for the Connect settings""" + uiSchema: JSON! + + """The values for the Connect settings""" + values: ConnectSettingsValues! +} + +type Connect implements Node { + id: PrefixedID! + + """The status of dynamic remote access""" + dynamicRemoteAccess: DynamicRemoteAccessStatus! + + """The settings for the Connect instance""" + settings: ConnectSettings! +} + +type Network implements Node { + id: PrefixedID! + accessUrls: [AccessUrl!] +} + +type ApiKeyResponse { + valid: Boolean! + error: String +} + +type MinigraphqlResponse { + status: MinigraphStatus! + timeout: Int + error: String +} + +"""The status of the minigraph""" +enum MinigraphStatus { + PRE_INIT + CONNECTING + CONNECTED + PING_FAILURE + ERROR_RETRYING +} + +type CloudResponse { + status: String! + ip: String + error: String +} + +type RelayResponse { + status: String! + timeout: String + error: String +} + +type Cloud { + error: String + apiKey: ApiKeyResponse! + relay: RelayResponse + minigraphql: MinigraphqlResponse! + cloud: CloudResponse! + allowedOrigins: [String!]! +} + +input AccessUrlObjectInput { + ipv4: String + ipv6: String + type: URL_TYPE! + name: String +} + "\n### Description:\n\nID scalar type that prefixes the underlying ID with the server identifier on output and strips it on input.\n\nWe use this scalar type to ensure that the ID is unique across all servers, allowing the same underlying resource ID to be used across different server instances.\n\n#### Input Behavior:\n\nWhen providing an ID as input (e.g., in arguments or input objects), the server identifier prefix (':') is optional.\n\n- If the prefix is present (e.g., '123:456'), it will be automatically stripped, and only the underlying ID ('456') will be used internally.\n- If the prefix is absent (e.g., '456'), the ID will be used as-is.\n\nThis makes it flexible for clients, as they don't strictly need to know or provide the server ID.\n\n#### Output Behavior:\n\nWhen an ID is returned in the response (output), it will *always* be prefixed with the current server's unique identifier (e.g., '123:456').\n\n#### Example:\n\nNote: The server identifier is '123' in this example.\n\n##### Input (Prefix Optional):\n```graphql\n# Both of these are valid inputs resolving to internal ID '456'\n{\n someQuery(id: \"123:456\") { ... }\n anotherQuery(id: \"456\") { ... }\n}\n```\n\n##### Output (Prefix Always Added):\n```graphql\n# Assuming internal ID is '456'\n{\n \"data\": {\n \"someResource\": {\n \"id\": \"123:456\" \n }\n }\n}\n```\n " scalar PrefixedID @@ -1504,6 +1669,10 @@ type Query { """List all installed plugins with their metadata""" plugins: [Plugin!]! + remoteAccess: RemoteAccess! + connect: Connect! + network: Network! + cloud: Cloud! } type Mutation { @@ -1546,6 +1715,11 @@ type Mutation { Remove one or more plugins from the API. Returns false if restart was triggered automatically, true if manual restart is required. """ removePlugin(input: PluginManagementInput!): Boolean! + updateApiSettings(input: ConnectSettingsInput!): ConnectSettingsValues! + connectSignIn(input: ConnectSignInInput!): Boolean! + connectSignOut: Boolean! + setupRemoteAccess(input: SetupRemoteAccessInput!): Boolean! + enableDynamicRemoteAccess(input: EnableDynamicRemoteAccessInput!): Boolean! } input NotificationData { @@ -1587,6 +1761,75 @@ input PluginManagementInput { restart: Boolean! = true } +input ConnectSettingsInput { + """The type of WAN access to use for Remote Access""" + accessType: WAN_ACCESS_TYPE + + """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. + """ + port: Int +} + +input ConnectSignInInput { + """The API key for authentication""" + apiKey: String! + + """The ID token for authentication""" + idToken: String + + """User information for the sign-in""" + userInfo: ConnectUserInfoInput + + """The access token for authentication""" + accessToken: String + + """The refresh token for authentication""" + refreshToken: String +} + +input ConnectUserInfoInput { + """The preferred username of the user""" + preferred_username: String! + + """The email address of the user""" + email: String! + + """The avatar URL of the user""" + avatar: String +} + +input SetupRemoteAccessInput { + """The type of WAN access to use for Remote Access""" + accessType: WAN_ACCESS_TYPE! + + """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. + """ + port: Int +} + +input EnableDynamicRemoteAccessInput { + """The AccessURL Input for dynamic remote access""" + url: AccessUrlInput! + + """Whether to enable or disable dynamic remote access""" + enabled: Boolean! +} + +input AccessUrlInput { + type: URL_TYPE! + name: String + ipv4: URL + ipv6: URL +} + type Subscription { displaySubscription: Display! infoSubscription: Info! diff --git a/api/src/unraid-api/auth/api-key.service.spec.ts b/api/src/unraid-api/auth/api-key.service.spec.ts index 0b926d333..46dae35c3 100644 --- a/api/src/unraid-api/auth/api-key.service.spec.ts +++ b/api/src/unraid-api/auth/api-key.service.spec.ts @@ -476,17 +476,148 @@ describe('ApiKeyService', () => { }); }); + describe('update', () => { + let updateMockApiKey: ApiKeyWithSecret; + + beforeEach(() => { + // Create a fresh copy of the mock data for update tests + updateMockApiKey = { + id: 'test-api-id', + key: 'test-api-key', + name: 'Test API Key', + description: 'Test API Key Description', + roles: [Role.GUEST], + permissions: [ + { + resource: Resource.CONNECT, + actions: [AuthActionVerb.READ], + }, + ], + createdAt: new Date().toISOString(), + }; + + vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([updateMockApiKey]); + vi.spyOn(apiKeyService, 'saveApiKey').mockResolvedValue(); + apiKeyService.onModuleInit(); + }); + + it('should update name and description', async () => { + const updatedName = 'Updated API Key'; + const updatedDescription = 'Updated Description'; + + const result = await apiKeyService.update({ + id: updateMockApiKey.id, + name: updatedName, + description: updatedDescription, + }); + + expect(result.name).toBe(updatedName); + expect(result.description).toBe(updatedDescription); + expect(result.roles).toEqual(updateMockApiKey.roles); + expect(result.permissions).toEqual(updateMockApiKey.permissions); + expect(apiKeyService.saveApiKey).toHaveBeenCalledWith(result); + }); + + it('should update roles', async () => { + const updatedRoles = [Role.ADMIN]; + + const result = await apiKeyService.update({ + id: updateMockApiKey.id, + roles: updatedRoles, + }); + + expect(result.roles).toEqual(updatedRoles); + expect(result.name).toBe(updateMockApiKey.name); + expect(result.description).toBe(updateMockApiKey.description); + expect(result.permissions).toEqual(updateMockApiKey.permissions); + expect(apiKeyService.saveApiKey).toHaveBeenCalledWith(result); + }); + + it('should update permissions', async () => { + const updatedPermissions = [ + { + resource: Resource.CONNECT, + actions: [AuthActionVerb.READ, AuthActionVerb.UPDATE], + }, + ]; + + const result = await apiKeyService.update({ + id: updateMockApiKey.id, + permissions: updatedPermissions, + }); + + expect(result.permissions).toEqual(updatedPermissions); + expect(result.name).toBe(updateMockApiKey.name); + expect(result.description).toBe(updateMockApiKey.description); + expect(result.roles).toEqual(updateMockApiKey.roles); + expect(apiKeyService.saveApiKey).toHaveBeenCalledWith(result); + }); + + it('should throw error when API key not found', async () => { + await expect( + apiKeyService.update({ + id: 'non-existent-id', + name: 'New Name', + }) + ).rejects.toThrow('API key not found'); + }); + + it('should throw error when invalid role is provided', async () => { + await expect( + apiKeyService.update({ + id: updateMockApiKey.id, + roles: ['INVALID_ROLE' as Role], + }) + ).rejects.toThrow('Invalid role specified'); + }); + + it('should throw error when invalid name is provided', async () => { + await expect( + apiKeyService.update({ + id: updateMockApiKey.id, + name: 'Invalid@Name', + }) + ).rejects.toThrow( + 'API key name must contain only letters, numbers, and spaces (Unicode letters are supported)' + ); + }); + }); + describe('loadAllFromDisk', () => { + let loadMockApiKey: ApiKeyWithSecret; + + beforeEach(() => { + // Create a fresh copy of the mock data for loadAllFromDisk tests + loadMockApiKey = { + id: 'test-api-id', + key: 'test-api-key', + name: 'Test API Key', + description: 'Test API Key Description', + roles: [Role.GUEST], + permissions: [ + { + resource: Resource.CONNECT, + actions: [AuthActionVerb.READ], + }, + ], + createdAt: new Date().toISOString(), + }; + }); + it('should load and parse all JSON files', async () => { const mockFiles = ['key1.json', 'key2.json', 'notakey.txt']; + const secondKey = { ...loadMockApiKey, id: 'second-id', key: 'second-key' }; vi.mocked(readdir).mockResolvedValue(mockFiles as any); - vi.mocked(readFile).mockResolvedValue(JSON.stringify(mockApiKeyWithSecret)); + vi.mocked(readFile) + .mockResolvedValueOnce(JSON.stringify(loadMockApiKey)) + .mockResolvedValueOnce(JSON.stringify(secondKey)); const result = await apiKeyService.loadAllFromDisk(); expect(result).toHaveLength(2); - expect(result[0]).toEqual(mockApiKeyWithSecret); + expect(result[0]).toEqual(loadMockApiKey); + expect(result[1]).toEqual(secondKey); expect(readFile).toHaveBeenCalledTimes(2); }); @@ -508,14 +639,12 @@ describe('ApiKeyService', () => { 'notakey.txt', ] as any); vi.mocked(readFile) - .mockResolvedValueOnce(JSON.stringify(mockApiKeyWithSecret)) + .mockResolvedValueOnce(JSON.stringify(loadMockApiKey)) .mockResolvedValueOnce(JSON.stringify({ invalid: 'structure' })) .mockResolvedValueOnce( - JSON.stringify({ ...mockApiKeyWithSecret, id: 'unique-id', key: 'unique-key' }) - ) - .mockResolvedValueOnce( - JSON.stringify({ ...mockApiKeyWithSecret, id: 'unique-id', key: 'unique-key' }) + JSON.stringify({ ...loadMockApiKey, id: 'unique-id', key: 'unique-key' }) ); + const result = await apiKeyService.loadAllFromDisk(); expect(result).toHaveLength(2); expect(result[0]).toEqual({ diff --git a/api/src/unraid-api/auth/api-key.service.ts b/api/src/unraid-api/auth/api-key.service.ts index bbf72ce45..b6cdf0257 100644 --- a/api/src/unraid-api/auth/api-key.service.ts +++ b/api/src/unraid-api/auth/api-key.service.ts @@ -374,4 +374,40 @@ export class ApiKeyService implements OnModuleInit { throw errors; } } + + async update({ + id, + name, + description, + roles, + permissions, + }: { + id: string; + name?: string; + description?: string; + roles?: Role[]; + permissions?: Permission[] | AddPermissionInput[]; + }): Promise { + const apiKey = this.findByIdWithSecret(id); + if (!apiKey) { + throw new GraphQLError('API key not found'); + } + if (name) { + apiKey.name = this.sanitizeName(name.trim()); + } + if (description !== undefined) { + apiKey.description = description; + } + if (roles) { + if (roles.some((role) => !ApiKeyService.validRoles.has(role))) { + throw new GraphQLError('Invalid role specified'); + } + apiKey.roles = roles; + } + if (permissions) { + apiKey.permissions = permissions; + } + await this.saveApiKey(apiKey); + return apiKey; + } } diff --git a/api/src/unraid-api/graph/graph.module.ts b/api/src/unraid-api/graph/graph.module.ts index 6307be594..e03e22190 100644 --- a/api/src/unraid-api/graph/graph.module.ts +++ b/api/src/unraid-api/graph/graph.module.ts @@ -17,6 +17,8 @@ import { createSandboxPlugin } from '@app/unraid-api/graph/sandbox-plugin.js'; import { GlobalDepsModule } from '@app/unraid-api/plugin/global-deps.module.js'; import { PluginModule } from '@app/unraid-api/plugin/plugin.module.js'; +console.log('ENVIRONMENT', ENVIRONMENT); + @Module({ imports: [ GlobalDepsModule, diff --git a/api/src/unraid-api/graph/resolvers/api-key/api-key.model.ts b/api/src/unraid-api/graph/resolvers/api-key/api-key.model.ts index 7bae69d09..ac4a4e583 100644 --- a/api/src/unraid-api/graph/resolvers/api-key/api-key.model.ts +++ b/api/src/unraid-api/graph/resolvers/api-key/api-key.model.ts @@ -14,6 +14,8 @@ import { ValidateNested, } from 'class-validator'; +import { AtLeastOneOf } from '@app/unraid-api/graph/resolvers/validation.utils.js'; + @ObjectType() export class Permission { @Field(() => Resource) @@ -108,6 +110,46 @@ export class CreateApiKeyInput { @IsBoolean() @IsOptional() overwrite?: boolean; + + @AtLeastOneOf(['roles', 'permissions'], { + message: 'At least one role or one permission is required to create an API key.', + }) + _atLeastOne!: boolean; +} + +@InputType() +export class UpdateApiKeyInput { + @Field(() => PrefixedID) + @IsString() + id!: string; + + @Field({ nullable: true }) + @IsString() + @IsOptional() + name?: string; + + @Field({ nullable: true }) + @IsString() + @IsOptional() + description?: string; + + @Field(() => [Role], { nullable: true }) + @IsArray() + @IsEnum(Role, { each: true }) + @IsOptional() + roles?: Role[]; + + @Field(() => [AddPermissionInput], { nullable: true }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AddPermissionInput) + @IsOptional() + permissions?: AddPermissionInput[]; + + @AtLeastOneOf(['roles', 'permissions'], { + message: 'At least one role or one permission is required to update an API key.', + }) + _atLeastOne!: boolean; } @InputType() diff --git a/api/src/unraid-api/graph/resolvers/api-key/api-key.mutation.spec.ts b/api/src/unraid-api/graph/resolvers/api-key/api-key.mutation.spec.ts index 37bda7caa..95547a73a 100644 --- a/api/src/unraid-api/graph/resolvers/api-key/api-key.mutation.spec.ts +++ b/api/src/unraid-api/graph/resolvers/api-key/api-key.mutation.spec.ts @@ -59,6 +59,7 @@ describe('ApiKeyMutationsResolver', () => { description: 'New API Key Description', roles: [Role.GUEST], permissions: [], + _atLeastOne: undefined, }; vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockApiKeyWithSecret); @@ -83,6 +84,7 @@ describe('ApiKeyMutationsResolver', () => { description: 'Should fail', roles: [Role.GUEST], permissions: [], + _atLeastOne: undefined, }; vi.spyOn(apiKeyService, 'create').mockRejectedValue(new Error('Create failed')); await expect(resolver.create(input)).rejects.toThrow('Create failed'); @@ -94,6 +96,7 @@ describe('ApiKeyMutationsResolver', () => { description: 'Should fail sync', roles: [Role.GUEST], permissions: [], + _atLeastOne: undefined, }; vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockApiKeyWithSecret); vi.spyOn(authService, 'syncApiKeyRoles').mockRejectedValue(new Error('Sync failed')); @@ -106,6 +109,7 @@ describe('ApiKeyMutationsResolver', () => { description: 'No name', roles: [Role.GUEST], permissions: [], + _atLeastOne: undefined, }; await expect(resolver.create(input)).rejects.toThrow(); }); diff --git a/api/src/unraid-api/graph/resolvers/api-key/api-key.mutation.ts b/api/src/unraid-api/graph/resolvers/api-key/api-key.mutation.ts index 592722999..d1ce77043 100644 --- a/api/src/unraid-api/graph/resolvers/api-key/api-key.mutation.ts +++ b/api/src/unraid-api/graph/resolvers/api-key/api-key.mutation.ts @@ -15,6 +15,7 @@ import { CreateApiKeyInput, DeleteApiKeyInput, RemoveRoleFromApiKeyInput, + UpdateApiKeyInput, } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js'; import { ApiKeyMutations } from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js'; import { validateObject } from '@app/unraid-api/graph/resolvers/validation.utils.js'; @@ -32,8 +33,7 @@ export class ApiKeyMutationsResolver { possession: AuthPossession.ANY, }) @ResolveField(() => ApiKeyWithSecret, { description: 'Create an API key' }) - async create(@Args('input') unvalidatedInput: CreateApiKeyInput): Promise { - const input = await validateObject(CreateApiKeyInput, unvalidatedInput); + async create(@Args('input') input: CreateApiKeyInput): Promise { const apiKey = await this.apiKeyService.create({ name: input.name, description: input.description ?? undefined, @@ -52,8 +52,7 @@ export class ApiKeyMutationsResolver { }) @ResolveField(() => Boolean, { description: 'Add a role to an API key' }) async addRole(@Args('input') input: AddRoleForApiKeyInput): Promise { - const validatedInput = await validateObject(AddRoleForApiKeyInput, input); - return this.authService.addRoleToApiKey(validatedInput.apiKeyId, Role[validatedInput.role]); + return this.authService.addRoleToApiKey(input.apiKeyId, Role[input.role]); } @UsePermissions({ @@ -63,8 +62,7 @@ export class ApiKeyMutationsResolver { }) @ResolveField(() => Boolean, { description: 'Remove a role from an API key' }) async removeRole(@Args('input') input: RemoveRoleFromApiKeyInput): Promise { - const validatedInput = await validateObject(RemoveRoleFromApiKeyInput, input); - return this.authService.removeRoleFromApiKey(validatedInput.apiKeyId, Role[validatedInput.role]); + return this.authService.removeRoleFromApiKey(input.apiKeyId, Role[input.role]); } @UsePermissions({ @@ -74,8 +72,19 @@ export class ApiKeyMutationsResolver { }) @ResolveField(() => Boolean, { description: 'Delete one or more API keys' }) async delete(@Args('input') input: DeleteApiKeyInput): Promise { - const validatedInput = await validateObject(DeleteApiKeyInput, input); - await this.apiKeyService.deleteApiKeys(validatedInput.ids); + await this.apiKeyService.deleteApiKeys(input.ids); return true; } + + @UsePermissions({ + action: AuthActionVerb.UPDATE, + resource: Resource.API_KEY, + possession: AuthPossession.ANY, + }) + @ResolveField(() => ApiKeyWithSecret, { description: 'Update an API key' }) + async update(@Args('input') input: UpdateApiKeyInput): Promise { + const apiKey = await this.apiKeyService.update(input); + await this.authService.syncApiKeyRoles(apiKey.id, apiKey.roles); + return apiKey; + } } diff --git a/api/src/unraid-api/graph/resolvers/validation.utils.ts b/api/src/unraid-api/graph/resolvers/validation.utils.ts index c807ac51c..547d8d33b 100644 --- a/api/src/unraid-api/graph/resolvers/validation.utils.ts +++ b/api/src/unraid-api/graph/resolvers/validation.utils.ts @@ -1,7 +1,13 @@ import 'reflect-metadata'; import { plainToClass } from 'class-transformer'; -import { validate, ValidationError } from 'class-validator'; +import { + registerDecorator, + validate, + ValidationArguments, + ValidationError, + ValidationOptions, +} from 'class-validator'; /** * Validates an object against a class using class-validator @@ -32,3 +38,26 @@ export async function validateObject(type: new () => T, object return instance; } + +/** + * Custom validator to ensure at least one of the given properties is a non-empty array + */ +export function AtLeastOneOf(properties: string[], validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + name: 'atLeastOneOf', + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + validator: { + validate(_: any, args: ValidationArguments) { + const obj = args.object as any; + return properties.some((prop) => Array.isArray(obj[prop]) && obj[prop].length > 0); + }, + defaultMessage(args: ValidationArguments) { + return `At least one of the following must be a non-empty array: ${properties.join(', ')}`; + }, + }, + }); + }; +} diff --git a/api/src/unraid-api/graph/validate-schema.ts b/api/src/unraid-api/graph/validate-schema.ts deleted file mode 100644 index 2c09c8765..000000000 --- a/api/src/unraid-api/graph/validate-schema.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { readFileSync } from 'fs'; -import { join } from 'path'; - -import { buildSchema } from 'graphql'; - -async function validateSchema(schemaFile = 'generated-schema.graphql') { - try { - // Read the generated schema file - const schemaPath = join(process.cwd(), schemaFile); - const schemaContent = readFileSync(schemaPath, 'utf-8'); - - // Try to build the schema - const schema = buildSchema(schemaContent); - - // If we get here, the schema is valid - console.log(`✅ ${schemaFile} is valid!`); - - // Print some basic schema information - const queryType = schema.getQueryType(); - const mutationType = schema.getMutationType(); - const subscriptionType = schema.getSubscriptionType(); - - console.log('\nSchema Overview:'); - console.log('----------------'); - if (queryType) { - console.log(`Query Type: ${queryType.name}`); - console.log('Query Fields:', Object.keys(queryType.getFields()).join(', ')); - } - if (mutationType) { - console.log(`\nMutation Type: ${mutationType.name}`); - console.log('Mutation Fields:', Object.keys(mutationType.getFields()).join(', ')); - } - if (subscriptionType) { - console.log(`\nSubscription Type: ${subscriptionType.name}`); - console.log('Subscription Fields:', Object.keys(subscriptionType.getFields()).join(', ')); - } - } catch (error) { - console.error('❌ Schema validation failed!'); - console.error('\nError details:'); - console.error('----------------'); - console.error(error); - - // If it's a GraphQL error, try to extract more information - if (error instanceof Error) { - const message = error.message; - if (message.includes('Cannot determine a GraphQL output type')) { - console.error('\nPossible causes:'); - console.error('1. Missing @Field() decorator on a type field'); - console.error('2. Unregistered enum type'); - console.error('3. Circular dependency in type definitions'); - console.error('\nLook for fields named "type" in your GraphQL types'); - } - } - } -} - -// Run the validation -validateSchema('generated-schema.graphql').catch(console.error); -validateSchema('generated-schema-new.graphql').catch(console.error); diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/__test__/__fixtures__/downloaded/.login.php.last-download-time b/api/src/unraid-api/unraid-file-modifier/modifications/__test__/__fixtures__/downloaded/.login.php.last-download-time index e65dcae71..cb0b28029 100644 --- a/api/src/unraid-api/unraid-file-modifier/modifications/__test__/__fixtures__/downloaded/.login.php.last-download-time +++ b/api/src/unraid-api/unraid-file-modifier/modifications/__test__/__fixtures__/downloaded/.login.php.last-download-time @@ -1 +1 @@ -1749572423916 \ No newline at end of file +1750189490614 \ No newline at end of file diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/__test__/__fixtures__/downloaded/DefaultPageLayout.php.last-download-time b/api/src/unraid-api/unraid-file-modifier/modifications/__test__/__fixtures__/downloaded/DefaultPageLayout.php.last-download-time index a66d748e9..ec69dd1a8 100644 --- a/api/src/unraid-api/unraid-file-modifier/modifications/__test__/__fixtures__/downloaded/DefaultPageLayout.php.last-download-time +++ b/api/src/unraid-api/unraid-file-modifier/modifications/__test__/__fixtures__/downloaded/DefaultPageLayout.php.last-download-time @@ -1 +1 @@ -1749572423555 \ No newline at end of file +1750189490326 \ No newline at end of file diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/__test__/__fixtures__/downloaded/Notifications.page.last-download-time b/api/src/unraid-api/unraid-file-modifier/modifications/__test__/__fixtures__/downloaded/Notifications.page.last-download-time index a82d2b3be..492cfcd89 100644 --- a/api/src/unraid-api/unraid-file-modifier/modifications/__test__/__fixtures__/downloaded/Notifications.page.last-download-time +++ b/api/src/unraid-api/unraid-file-modifier/modifications/__test__/__fixtures__/downloaded/Notifications.page.last-download-time @@ -1 +1 @@ -1749572423759 \ No newline at end of file +1750189490462 \ No newline at end of file diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/__test__/__fixtures__/downloaded/auth-request.php.last-download-time b/api/src/unraid-api/unraid-file-modifier/modifications/__test__/__fixtures__/downloaded/auth-request.php.last-download-time index 52aa0e34d..90ca73236 100644 --- a/api/src/unraid-api/unraid-file-modifier/modifications/__test__/__fixtures__/downloaded/auth-request.php.last-download-time +++ b/api/src/unraid-api/unraid-file-modifier/modifications/__test__/__fixtures__/downloaded/auth-request.php.last-download-time @@ -1 +1 @@ -1749572424097 \ No newline at end of file +1750189490730 \ No newline at end of file diff --git a/generated-schema-new.graphql b/generated-schema-new.graphql deleted file mode 100644 index 8a0c87333..000000000 --- a/generated-schema-new.graphql +++ /dev/null @@ -1,1528 +0,0 @@ -# ------------------------------------------------------ -# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY) -# ------------------------------------------------------ - -type ApiKeyResponse { - valid: Boolean! - error: String -} - -type MinigraphqlResponse { - status: MinigraphStatus! - timeout: Int - error: String -} - -enum MinigraphStatus { - PRE_INIT - CONNECTING - CONNECTED - PING_FAILURE - ERROR_RETRYING -} - -type CloudResponse { - status: String! - ip: String - error: String -} - -type RelayResponse { - status: String! - timeout: String - error: String -} - -type Cloud { - error: String - apiKey: ApiKeyResponse! - relay: RelayResponse - minigraphql: MinigraphqlResponse! - cloud: CloudResponse! - allowedOrigins: [String!]! -} - -type Capacity { - """Free capacity""" - free: String! - - """Used capacity""" - used: String! - - """Total capacity""" - total: String! -} - -type ArrayCapacity { - """Capacity in kilobytes""" - kilobytes: Capacity! - - """Capacity in number of disks""" - disks: Capacity! -} - -type ArrayDisk { - """Disk identifier, only set for present disks on the system""" - id: ID! - - """ - Array slot number. Parity1 is always 0 and Parity2 is always 29. Array slots will be 1 - 28. Cache slots are 30 - 53. Flash is 54. - """ - idx: Int! - name: String - device: String - - """(KB) Disk Size total""" - size: Float! - status: ArrayDiskStatus - - """Is the disk a HDD or SSD.""" - rotational: Boolean - - """Disk temp - will be NaN if array is not started or DISK_NP""" - temp: Int - - """ - Count of I/O read requests sent to the device I/O drivers. These statistics may be cleared at any time. - """ - numReads: Float! - - """ - Count of I/O writes requests sent to the device I/O drivers. These statistics may be cleared at any time. - """ - numWrites: Float! - - """ - Number of unrecoverable errors reported by the device I/O drivers. Missing data due to unrecoverable array read errors is filled in on-the-fly using parity reconstruct (and we attempt to write this data back to the sector(s) which failed). Any unrecoverable write error results in disabling the disk. - """ - numErrors: Float! - - """(KB) Total Size of the FS (Not present on Parity type drive)""" - fsSize: Float - - """(KB) Free Size on the FS (Not present on Parity type drive)""" - fsFree: Float - - """(KB) Used Size on the FS (Not present on Parity type drive)""" - fsUsed: Float - exportable: Boolean - - """Type of Disk - used to differentiate Cache / Flash / Array / Parity""" - type: ArrayDiskType! - - """(%) Disk space left to warn""" - warning: Int - - """(%) Disk space left for critical""" - critical: Int - - """File system type for the disk""" - fsType: String - - """User comment on disk""" - comment: String - - """File format (ex MBR: 4KiB-aligned)""" - format: String - - """ata | nvme | usb | (others)""" - transport: String - color: ArrayDiskFsColor -} - -enum ArrayDiskStatus { - DISK_NP - DISK_OK - DISK_NP_MISSING - DISK_INVALID - DISK_WRONG - DISK_DSBL - DISK_NP_DSBL - DISK_DSBL_NEW - DISK_NEW -} - -enum ArrayDiskType { - DATA - PARITY - FLASH - CACHE -} - -enum ArrayDiskFsColor { - GREEN_ON - GREEN_BLINK - BLUE_ON - BLUE_BLINK - YELLOW_ON - YELLOW_BLINK - RED_ON - RED_OFF - GREY_OFF -} - -type UnraidArray { - id: ID! - - """Array state before this query/mutation""" - previousState: ArrayState - - """Array state after this query/mutation""" - pendingState: ArrayPendingState - - """Current array state""" - state: ArrayState! - - """Current array capacity""" - capacity: ArrayCapacity! - - """Current boot disk""" - boot: ArrayDisk - - """Parity disks in the current array""" - parities: [ArrayDisk!]! - - """Data disks in the current array""" - disks: [ArrayDisk!]! - - """Caches in the current array""" - caches: [ArrayDisk!]! -} - -enum ArrayState { - STARTED - STOPPED - NEW_ARRAY - RECON_DISK - DISABLE_DISK - SWAP_DSBL - INVALID_EXPANSION - PARITY_NOT_BIGGEST - TOO_MANY_MISSING_DISKS - NEW_DISK_TOO_SMALL - NO_DATA_DISKS -} - -enum ArrayPendingState { - STARTING - STOPPING - NO_DATA_DISKS - TOO_MANY_MISSING_DISKS -} - -type Share { - id: ID! - - """Display name""" - name: String - - """(KB) Free space""" - free: Long - - """(KB) Used Size""" - used: Long - - """(KB) Total size""" - size: Long - - """Disks that are included in this share""" - include: [String!] - - """Disks that are excluded from this share""" - exclude: [String!] - - """Is this share cached""" - cache: Boolean - - """Original name""" - nameOrig: String - - """User comment""" - comment: String - - """Allocator""" - allocator: String - - """Split level""" - splitLevel: String - - """Floor""" - floor: String - - """COW""" - cow: String - - """Color""" - color: String - - """LUKS status""" - luksStatus: String -} - -"""The `Long` scalar type represents 52-bit integers""" -scalar Long - -type RemoteAccess { - """The type of WAN access used for Remote Access""" - accessType: WAN_ACCESS_TYPE! - - """The type of port forwarding used for Remote Access""" - forwardType: WAN_FORWARD_TYPE - - """The port used for Remote Access""" - port: Int -} - -enum WAN_ACCESS_TYPE { - DYNAMIC - ALWAYS - DISABLED -} - -enum WAN_FORWARD_TYPE { - UPNP - STATIC -} - -type DynamicRemoteAccessStatus { - """The type of dynamic remote access that is enabled""" - enabledType: DynamicRemoteAccessType! - - """The type of dynamic remote access that is currently running""" - runningType: DynamicRemoteAccessType! - - """Any error message associated with the dynamic remote access""" - error: String -} - -enum DynamicRemoteAccessType { - STATIC - UPNP - DISABLED -} - -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. - """ - sandbox: Boolean! - - """A list of origins allowed to interact with the API""" - extraOrigins: [String!]! - - """The type of WAN access used for Remote Access""" - accessType: WAN_ACCESS_TYPE! - - """The type of port forwarding used for Remote Access""" - forwardType: WAN_FORWARD_TYPE - - """The port used for Remote Access""" - port: Int - - """A list of Unique Unraid Account ID's""" - ssoUserIds: [String!]! -} - -type ConnectSettings { - """The unique identifier for the Connect settings""" - id: ID! - - """The data schema for the Connect settings""" - dataSchema: JSON! - - """The UI schema for the Connect settings""" - uiSchema: JSON! - - """The values for the Connect settings""" - values: ConnectSettingsValues! -} - -""" -The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). -""" -scalar JSON @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf") - -type Connect { - """The unique identifier for the Connect instance""" - id: ID! - - """The status of dynamic remote access""" - dynamicRemoteAccess: DynamicRemoteAccessStatus! - - """The settings for the Connect instance""" - settings: ConnectSettings! -} - -type AccessUrl { - type: URL_TYPE! - name: String - ipv4: URL - ipv6: URL -} - -enum URL_TYPE { - LAN - WIREGUARD - WAN - MDNS - OTHER - DEFAULT -} - -""" -A field whose value conforms to the standard URL format as specified in RFC3986: https://www.ietf.org/rfc/rfc3986.txt. -""" -scalar URL - -type Network { - id: ID! - accessUrls: [AccessUrl!] -} - -type ProfileModel { - userId: ID - username: String! - url: String! - avatar: String! -} - -type Server { - owner: ProfileModel! - guid: String! - apikey: String! - name: String! - status: ServerStatus! - wanip: String! - lanip: String! - localurl: String! - remoteurl: String! -} - -enum ServerStatus { - ONLINE - OFFLINE - NEVER_CONNECTED -} - -type DiskPartition { - """The name of the partition""" - name: String! - - """The filesystem type of the partition""" - fsType: DiskFsType! - - """The size of the partition in bytes""" - size: Float! -} - -"""The type of filesystem on the disk partition""" -enum DiskFsType { - XFS - BTRFS - VFAT - ZFS - EXT4 - NTFS -} - -type Disk { - """The unique identifier of the disk""" - id: String! - - """The device path of the disk (e.g. /dev/sdb)""" - device: String! - - """The type of disk (e.g. SSD, HDD)""" - type: String! - - """The model name of the disk""" - name: String! - - """The manufacturer of the disk""" - vendor: String! - - """The total size of the disk in bytes""" - size: Float! - - """The number of bytes per sector""" - bytesPerSector: Float! - - """The total number of cylinders on the disk""" - totalCylinders: Float! - - """The total number of heads on the disk""" - totalHeads: Float! - - """The total number of sectors on the disk""" - totalSectors: Float! - - """The total number of tracks on the disk""" - totalTracks: Float! - - """The number of tracks per cylinder""" - tracksPerCylinder: Float! - - """The number of sectors per track""" - sectorsPerTrack: Float! - - """The firmware revision of the disk""" - firmwareRevision: String! - - """The serial number of the disk""" - serialNum: String! - - """The interface type of the disk""" - interfaceType: DiskInterfaceType! - - """The SMART status of the disk""" - smartStatus: DiskSmartStatus! - - """The current temperature of the disk in Celsius""" - temperature: Float - - """The partitions on the disk""" - partitions: [DiskPartition!]! -} - -"""The type of interface the disk uses to connect to the system""" -enum DiskInterfaceType { - SAS - SATA - USB - PCIE - UNKNOWN -} - -""" -The SMART (Self-Monitoring, Analysis and Reporting Technology) status of the disk -""" -enum DiskSmartStatus { - OK - UNKNOWN -} - -type KeyFile { - location: String - contents: String -} - -type Registration { - guid: ID - type: registrationType - keyFile: KeyFile - state: RegistrationState - expiration: String - updateExpiration: String -} - -enum registrationType { - BASIC - PLUS - PRO - STARTER - UNLEASHED - LIFETIME - INVALID - TRIAL -} - -enum RegistrationState { - TRIAL - BASIC - PLUS - PRO - STARTER - UNLEASHED - LIFETIME - EEXPIRED - EGUID - EGUID1 - ETRIAL - ENOKEYFILE - ENOKEYFILE1 - ENOKEYFILE2 - ENOFLASH - ENOFLASH1 - ENOFLASH2 - ENOFLASH3 - ENOFLASH4 - ENOFLASH5 - ENOFLASH6 - ENOFLASH7 - EBLACKLISTED - EBLACKLISTED1 - EBLACKLISTED2 - ENOCONN -} - -type Vars { - id: ID! - - """Unraid version""" - version: String - maxArraysz: Int - maxCachesz: Int - - """Machine hostname""" - name: String - timeZone: String - comment: String - security: String - workgroup: String - domain: String - domainShort: String - hideDotFiles: Boolean - localMaster: Boolean - enableFruit: String - - """Should a NTP server be used for time sync?""" - useNtp: Boolean - - """NTP Server 1""" - ntpServer1: String - - """NTP Server 2""" - ntpServer2: String - - """NTP Server 3""" - ntpServer3: String - - """NTP Server 4""" - ntpServer4: String - domainLogin: String - sysModel: String - sysArraySlots: Int - sysCacheSlots: Int - sysFlashSlots: Int - useSsl: Boolean - - """Port for the webui via HTTP""" - port: Int - - """Port for the webui via HTTPS""" - portssl: Int - localTld: String - bindMgt: Boolean - - """Should telnet be enabled?""" - useTelnet: Boolean - porttelnet: Int - useSsh: Boolean - portssh: Int - startPage: String - startArray: Boolean - spindownDelay: String - queueDepth: String - spinupGroups: Boolean - defaultFormat: String - defaultFsType: String - shutdownTimeout: Int - luksKeyfile: String - pollAttributes: String - pollAttributesDefault: String - pollAttributesStatus: String - nrRequests: Int - nrRequestsDefault: Int - nrRequestsStatus: String - mdNumStripes: Int - mdNumStripesDefault: Int - mdNumStripesStatus: String - mdSyncWindow: Int - mdSyncWindowDefault: Int - mdSyncWindowStatus: String - mdSyncThresh: Int - mdSyncThreshDefault: Int - mdSyncThreshStatus: String - mdWriteMethod: Int - mdWriteMethodDefault: String - mdWriteMethodStatus: String - shareDisk: String - shareUser: String - shareUserInclude: String - shareUserExclude: String - shareSmbEnabled: Boolean - shareNfsEnabled: Boolean - shareAfpEnabled: Boolean - shareInitialOwner: String - shareInitialGroup: String - shareCacheEnabled: Boolean - shareCacheFloor: String - shareMoverSchedule: String - shareMoverLogging: Boolean - fuseRemember: String - fuseRememberDefault: String - fuseRememberStatus: String - fuseDirectio: String - fuseDirectioDefault: String - fuseDirectioStatus: String - shareAvahiEnabled: Boolean - shareAvahiSmbName: String - shareAvahiSmbModel: String - shareAvahiAfpName: String - shareAvahiAfpModel: String - safeMode: Boolean - startMode: String - configValid: Boolean - configError: ConfigErrorState - joinStatus: String - deviceCount: Int - flashGuid: String - flashProduct: String - flashVendor: String - regCheck: String - regFile: String - regGuid: String - regTy: registrationType - regState: String - - """Registration owner""" - regTo: String - regTm: String - regTm2: String - regGen: String - sbName: String - sbVersion: String - sbUpdated: String - sbEvents: Int - sbState: String - sbClean: Boolean - sbSynced: Int - sbSyncErrs: Int - sbSynced2: Int - sbSyncExit: String - sbNumDisks: Int - mdColor: String - mdNumDisks: Int - mdNumDisabled: Int - mdNumInvalid: Int - mdNumMissing: Int - mdNumNew: Int - mdNumErased: Int - mdResync: Int - mdResyncCorr: String - mdResyncPos: String - mdResyncDb: String - mdResyncDt: String - mdResyncAction: String - mdResyncSize: Int - mdState: String - mdVersion: String - cacheNumDevices: Int - cacheSbNumDisks: Int - fsState: String - - """Human friendly string of array events happening""" - fsProgress: String - - """ - Percentage from 0 - 100 while upgrading a disk or swapping parity drives - """ - fsCopyPrcnt: Int - fsNumMounted: Int - fsNumUnmountable: Int - fsUnmountableMask: String - - """Total amount of user shares""" - shareCount: Int - - """Total amount shares with SMB enabled""" - shareSmbCount: Int - - """Total amount shares with NFS enabled""" - shareNfsCount: Int - - """Total amount shares with AFP enabled""" - shareAfpCount: Int - shareMoverActive: Boolean - csrfToken: String -} - -"""Possible error states for configuration""" -enum ConfigErrorState { - UNKNOWN_ERROR - INELIGIBLE - INVALID - NO_KEY_SERVER - WITHDRAWN -} - -type Permission { - resource: Resource! - actions: [String!]! -} - -"""Available resources for permissions""" -enum Resource { - API_KEY - ARRAY - CLOUD - CONFIG - CONNECT - CONNECT__REMOTE_ACCESS - CUSTOMIZATIONS - DASHBOARD - DISK - DISPLAY - DOCKER - FLASH - INFO - LOGS - ME - NETWORK - NOTIFICATIONS - ONLINE - OS - OWNER - PERMISSION - REGISTRATION - SERVERS - SERVICES - SHARE - VARS - VMS - WELCOME -} - -type ApiKey { - id: ID! - name: String! - description: String - roles: [Role!]! - createdAt: String! - permissions: [Permission!]! -} - -"""Available roles for API keys and users""" -enum Role { - ADMIN - CONNECT - GUEST -} - -type ApiKeyWithSecret { - id: ID! - name: String! - description: String - roles: [Role!]! - createdAt: String! - permissions: [Permission!]! - key: String! -} - -type ArrayMutations { - """Placeholder field to ensure the type is not empty""" - _: Boolean! -} - -type DockerMutations { - """Placeholder field to ensure the type is not empty""" - _: Boolean! - - """Start a container""" - start(id: String!): DockerContainer! - - """Stop a container""" - stop(id: String!): DockerContainer! -} - -type Config { - id: ID! - valid: Boolean - error: String -} - -type InfoApps { - id: ID! - - """How many docker containers are installed""" - installed: Int! - - """How many docker containers are running""" - started: Int! -} - -type Baseboard { - id: ID! - manufacturer: String! - model: String - version: String - serial: String - assetTag: String -} - -type InfoCpu { - id: ID! - manufacturer: String! - brand: String! - vendor: String! - family: String! - model: String! - stepping: Int! - revision: String! - voltage: String - speed: Float! - speedmin: Float! - speedmax: Float! - threads: Int! - cores: Int! - processors: Int! - socket: String! - cache: JSON! - flags: [String!]! -} - -type Gpu { - id: ID! - type: String! - typeid: String! - vendorname: String! - productid: String! - blacklisted: Boolean! - class: String! -} - -type Pci { - id: ID! - type: String - typeid: String - vendorname: String - vendorid: String - productname: String - productid: String - blacklisted: String - class: String -} - -type Usb { - id: ID! - name: String -} - -type Devices { - id: ID! - gpu: [Gpu!]! - pci: [Pci!]! - usb: [Usb!]! -} - -type Case { - icon: String - url: String - error: String - base64: String -} - -type Display { - id: ID! - case: Case - date: String - number: String - scale: Boolean - tabs: Boolean - users: String - resize: Boolean - wwn: Boolean - total: Boolean - usage: Boolean - banner: String - dashapps: String - theme: Theme - text: Boolean - unit: Temperature - warning: Int - critical: Int - hot: Int - max: Int - locale: String -} - -"""Display theme""" -enum Theme { - white -} - -"""Temperature unit (Celsius or Fahrenheit)""" -enum Temperature { - C - F -} - -type MemoryLayout { - size: Int! - bank: String - type: String - clockSpeed: Int - formFactor: String - manufacturer: String - partNum: String - serialNum: String - voltageConfigured: Int - voltageMin: Int - voltageMax: Int -} - -type InfoMemory { - id: ID! - max: Int! - total: Int! - free: Int! - used: Int! - active: Int! - available: Int! - buffcache: Int! - swaptotal: Int! - swapused: Int! - swapfree: Int! - layout: [MemoryLayout!]! -} - -type Os { - id: ID! - platform: String - distro: String - release: String - codename: String - kernel: String - arch: String - hostname: String - codepage: String - logofile: String - serial: String - build: String - uptime: String -} - -type System { - id: ID! - manufacturer: String - model: String - version: String - serial: String - uuid: String - sku: String -} - -type Versions { - id: ID! - kernel: String - openssl: String - systemOpenssl: String - systemOpensslLib: String - node: String - v8: String - npm: String - yarn: String - pm2: String - gulp: String - grunt: String - git: String - tsc: String - mysql: String - redis: String - mongodb: String - apache: String - nginx: String - php: String - docker: String - postfix: String - postgresql: String - perl: String - python: String - gcc: String - unraid: String -} - -type Info { - id: ID! - - """Count of docker containers""" - apps: InfoApps! - baseboard: Baseboard! - cpu: InfoCpu! - devices: Devices! - display: Display! - - """Machine ID""" - machineId: ID - memory: InfoMemory! - os: Os! - system: System! - time: DateTime! - versions: Versions! -} - -""" -A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. -""" -scalar DateTime - -type ContainerPort { - ip: String - privatePort: Int! - publicPort: Int! - type: ContainerPortType! -} - -enum ContainerPortType { - TCP - UDP -} - -type ContainerHostConfig { - networkMode: String! -} - -type DockerContainer { - id: ID! - names: [String!]! - image: String! - imageId: String! - command: String! - created: Int! - ports: [ContainerPort!]! - - """Total size of all the files in the container""" - sizeRootFs: Int - labels: JSONObject - state: ContainerState! - status: String! - hostConfig: ContainerHostConfig - networkSettings: JSONObject - mounts: [JSONObject!] - autoStart: Boolean! -} - -""" -The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). -""" -scalar JSONObject - -enum ContainerState { - RUNNING - EXITED -} - -type DockerNetwork { - name: String! - id: ID! - created: String! - scope: String! - driver: String! - enableIPv6: Boolean! - ipam: JSONObject! - internal: Boolean! - attachable: Boolean! - ingress: Boolean! - configFrom: JSONObject! - configOnly: Boolean! - containers: JSONObject! - options: JSONObject! - labels: JSONObject! -} - -type Docker { - id: ID! - containers: [DockerContainer!]! - networks: [DockerNetwork!]! -} - -type Flash { - id: ID! - guid: String! - vendor: String! - product: String! -} - -type LogFile { - """Name of the log file""" - name: String! - - """Full path to the log file""" - path: String! - - """Size of the log file in bytes""" - size: Int! - - """Last modified timestamp""" - modifiedAt: DateTime! -} - -type LogFileContent { - """Path to the log file""" - path: String! - - """Content of the log file""" - content: String! - - """Total number of lines in the file""" - totalLines: Int! - - """Starting line number of the content (1-indexed)""" - startLine: Int -} - -type NotificationCounts { - info: Int! - warning: Int! - alert: Int! - total: Int! -} - -type NotificationOverview { - unread: NotificationCounts! - archive: NotificationCounts! -} - -type Notification { - id: ID! - - """Also known as 'event'""" - title: String! - subject: String! - description: String! - importance: NotificationImportance! - link: String - type: NotificationType! - - """ISO Timestamp for when the notification occurred""" - timestamp: String - formattedTimestamp: String -} - -enum NotificationImportance { - ALERT - INFO - WARNING -} - -enum NotificationType { - UNREAD - ARCHIVE -} - -type Notifications { - id: ID! - - """A cached overview of the notifications in the system & their severity.""" - overview: NotificationOverview! - list(filter: NotificationFilter!): [Notification!]! -} - -input NotificationFilter { - importance: NotificationImportance - type: NotificationType! - offset: Int! - limit: Int! -} - -type Owner { - username: String! - url: String! - avatar: String! -} - -type VmDomain { - uuid: ID! - - """A friendly name for the vm""" - name: String - - """Current domain vm state""" - state: VmState! -} - -"""The state of a virtual machine""" -enum VmState { - NOSTATE - RUNNING - IDLE - PAUSED - SHUTDOWN - SHUTOFF - CRASHED - PMSUSPENDED -} - -type Vms { - id: ID! - domain: [VmDomain!]! - domains: [VmDomain!]! -} - -type Uptime { - timestamp: String -} - -type Service { - id: ID! - name: String - online: Boolean - uptime: Uptime - version: String -} - -type UserAccount { - """A unique identifier for the user""" - id: ID! - - """The name of the user""" - name: String! - - """A description of the user""" - description: String! - - """The roles of the user""" - roles: [Role!]! - - """The permissions of the user""" - permissions: [Permission!] -} - -type Query { - apiKeys: [ApiKey!]! - apiKey(id: String!): ApiKey - array: UnraidArray! - cloud: Cloud! - config: Config! - connect: Connect! - remoteAccess: RemoteAccess! - extraAllowedOrigins: [String!]! - display: Display! - flash: Flash! - info: Info! - logFiles: [LogFile!]! - logFile(path: String!, lines: Float, startLine: Float): LogFileContent! - me: UserAccount! - network: Network! - - """Get all notifications""" - notifications: Notifications! - online: Boolean! - owner: Owner! - registration: Registration - server: Server - servers: [Server!]! - services: [Service!]! - shares: [Share!]! - vars: Vars! - vms: Vms! - docker: Docker! - disks: [Disk!]! - disk(id: String!): Disk! -} - -type Mutation { - createApiKey(input: CreateApiKeyInput!): ApiKeyWithSecret! - addRoleForApiKey(input: AddRoleForApiKeyInput!): Boolean! - removeRoleFromApiKey(input: RemoveRoleFromApiKeyInput!): Boolean! - - """Set array state""" - setState(input: ArrayStateInput!): UnraidArray! - - """Add new disk to array""" - addDiskToArray(input: ArrayDiskInput!): UnraidArray! - - """ - Remove existing disk from array. NOTE: The array must be stopped before running this otherwise it'll throw an error. - """ - removeDiskFromArray(input: ArrayDiskInput!): UnraidArray! - - """Mount a disk in the array""" - mountArrayDisk(id: String!): ArrayDisk! - - """Unmount a disk from the array""" - unmountArrayDisk(id: String!): ArrayDisk! - - """Clear statistics for a disk in the array""" - clearArrayDiskStatistics(id: String!): Boolean! - updateApiSettings(input: ApiSettingsInput!): ConnectSettings! - enableDynamicRemoteAccess(input: EnableDynamicRemoteAccessInput!): Boolean! - connectSignIn(input: ConnectSignInInput!): Boolean! - connectSignOut: Boolean! - setupRemoteAccess(input: SetupRemoteAccessInput!): Boolean! - setAdditionalAllowedOrigins(input: AllowedOriginInput!): [String!]! - - """Creates a new notification record""" - createNotification(input: NotificationData!): Notification! - deleteNotification(id: String!, type: NotificationType!): NotificationOverview! - - """Deletes all archived notifications on server.""" - deleteArchivedNotifications: NotificationOverview! - - """Marks a notification as archived.""" - archiveNotification(id: String!): Notification! - archiveNotifications(ids: [String!]!): NotificationOverview! - archiveAll(importance: NotificationImportance): NotificationOverview! - - """Marks a notification as unread.""" - unreadNotification(id: String!): Notification! - unarchiveNotifications(ids: [String!]!): NotificationOverview! - unarchiveAll(importance: NotificationImportance): NotificationOverview! - - """Reads each notification to recompute & update the overview.""" - recalculateOverview: NotificationOverview! - - """Start a virtual machine""" - startVm(id: String!): Boolean! - - """Stop a virtual machine""" - stopVm(id: String!): Boolean! - - """Pause a virtual machine""" - pauseVm(id: String!): Boolean! - - """Resume a virtual machine""" - resumeVm(id: String!): Boolean! - - """Force stop a virtual machine""" - forceStopVm(id: String!): Boolean! - - """Reboot a virtual machine""" - rebootVm(id: String!): Boolean! - - """Reset a virtual machine""" - resetVm(id: String!): Boolean! -} - -input CreateApiKeyInput { - name: String! - description: String - roles: [Role!] - permissions: [AddPermissionInput!] - - """ - This will replace the existing key if one already exists with the same name, otherwise returns the existing key - """ - overwrite: Boolean -} - -input AddPermissionInput { - resource: Resource! - actions: [String!]! -} - -input AddRoleForApiKeyInput { - apiKeyId: ID! - role: Role! -} - -input RemoveRoleFromApiKeyInput { - apiKeyId: ID! - role: Role! -} - -input ArrayStateInput { - """Array state""" - desiredState: ArrayStateInputState! = STOP -} - -enum ArrayStateInputState { - START - STOP -} - -input ArrayDiskInput { - """Disk ID""" - id: ID! = "" - - """The slot for the disk""" - slot: Int -} - -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. - """ - sandbox: Boolean - - """A list of origins allowed to interact with the API""" - extraOrigins: [String!] - - """The type of WAN access to use for Remote Access""" - accessType: WAN_ACCESS_TYPE - - """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. - """ - port: Int - - """A list of Unique Unraid Account ID's""" - ssoUserIds: [String!] -} - -input EnableDynamicRemoteAccessInput { - """The URL for dynamic remote access""" - url: URL! - - """Whether to enable or disable dynamic remote access""" - enabled: Boolean! -} - -input ConnectSignInInput { - """The API key for authentication""" - apiKey: String! - - """The ID token for authentication""" - idToken: String - - """User information for the sign-in""" - userInfo: ConnectUserInfoInput - - """The access token for authentication""" - accessToken: String - - """The refresh token for authentication""" - refreshToken: String -} - -input ConnectUserInfoInput { - """The preferred username of the user""" - preferred_username: String! - - """The email address of the user""" - email: String! - - """The avatar URL of the user""" - avatar: String -} - -input SetupRemoteAccessInput { - """The type of WAN access to use for Remote Access""" - accessType: WAN_ACCESS_TYPE! - - """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. - """ - port: Int -} - -input AllowedOriginInput { - """A list of origins allowed to interact with the API""" - origins: [String!]! -} - -input NotificationData { - title: String! - subject: String! - description: String! - importance: NotificationImportance! - link: String -} - -type Subscription { - arraySubscription: UnraidArray! - displaySubscription: Display! - infoSubscription: Info! - logFileSubscription(path: String!): LogFileContent! - notificationAdded: Notification! - notificationsOverview: NotificationOverview! - ownerSubscription: Owner! - registrationSubscription: Registration! - serversSubscription: Server! -} \ No newline at end of file diff --git a/packages/unraid-api-plugin-connect/src/resolver/connect.resolver.ts b/packages/unraid-api-plugin-connect/src/resolver/connect.resolver.ts index b632ecc93..1fad61082 100644 --- a/packages/unraid-api-plugin-connect/src/resolver/connect.resolver.ts +++ b/packages/unraid-api-plugin-connect/src/resolver/connect.resolver.ts @@ -43,4 +43,5 @@ export class ConnectResolver { public async settings(): Promise { return {} as ConnectSettings; } + } diff --git a/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/ApiKeys.page b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/ApiKeys.page new file mode 100644 index 000000000..ff68d529c --- /dev/null +++ b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/ApiKeys.page @@ -0,0 +1,8 @@ +Menu="ManagementAccess:150" +Title="API Keys" +Icon="icon-u-key" +Tag="key" +--- + + + diff --git a/unraid-ui/eslint.config.ts b/unraid-ui/eslint.config.ts index bbf873f2e..f03e330c7 100644 --- a/unraid-ui/eslint.config.ts +++ b/unraid-ui/eslint.config.ts @@ -1,13 +1,16 @@ import eslint from '@eslint/js'; -// @ts-ignore-error No Declaration For This Plugin +// @ts-expect-error No Declaration For This Plugin import importPlugin from 'eslint-plugin-import'; import noRelativeImportPaths from 'eslint-plugin-no-relative-import-paths'; import prettier from 'eslint-plugin-prettier'; import vuePlugin from 'eslint-plugin-vue'; import tseslint from 'typescript-eslint'; +// Import vue-eslint-parser as an ESM import +import vueEslintParser from 'vue-eslint-parser'; // Common rules shared across file types const commonRules = { + '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }], '@typescript-eslint/no-unused-vars': ['off'], 'no-multiple-empty-lines': ['error', { max: 1, maxBOF: 0, maxEOF: 1 }], 'no-relative-import-paths/no-relative-import-paths': [ @@ -120,7 +123,7 @@ export default [ { files: ['**/*.vue'], languageOptions: { - parser: require('vue-eslint-parser'), + parser: vueEslintParser, parserOptions: { ...commonLanguageOptions, parser: tseslint.parser, @@ -146,6 +149,10 @@ export default [ // Ignores { - ignores: ['src/graphql/generated/client/**/*'], + ignores: [ + 'src/graphql/generated/client/**/*', + 'src/global.d.ts', + 'eslint.config.ts', + ], }, ]; diff --git a/unraid-ui/src/components.ts b/unraid-ui/src/components.ts index 97d6da9d7..182d8c1f0 100644 --- a/unraid-ui/src/components.ts +++ b/unraid-ui/src/components.ts @@ -19,3 +19,4 @@ export * from '@/components/common/toast'; export * from '@/components/common/popover'; export * from '@/components/modals'; export * from '@/components/common/accordion'; +export * from '@/components/common/dialog'; diff --git a/unraid-ui/src/components/common/accordion/AccordionItem.vue b/unraid-ui/src/components/common/accordion/AccordionItem.vue index 96bafc111..08b7018fb 100644 --- a/unraid-ui/src/components/common/accordion/AccordionItem.vue +++ b/unraid-ui/src/components/common/accordion/AccordionItem.vue @@ -12,7 +12,7 @@ const forwardedProps = useForwardProps(delegatedProps); diff --git a/unraid-ui/src/components/common/accordion/AccordionTrigger.vue b/unraid-ui/src/components/common/accordion/AccordionTrigger.vue index 0b1e70c5d..2a7e04f75 100644 --- a/unraid-ui/src/components/common/accordion/AccordionTrigger.vue +++ b/unraid-ui/src/components/common/accordion/AccordionTrigger.vue @@ -16,7 +16,7 @@ const delegatedProps = reactiveOmit(props, 'class'); v-bind="delegatedProps" :class=" cn( - 'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180', + 'flex flex-1 items-center justify-between p-2 rounded-md font-medium transition-all border border-border hover:border-muted-foreground focus:border-muted-foreground [&[data-state=open]>svg]:rotate-180', props.class ) " diff --git a/unraid-ui/src/components/common/badge/badge.variants.ts b/unraid-ui/src/components/common/badge/badge.variants.ts index 57be2fbfb..99d477984 100644 --- a/unraid-ui/src/components/common/badge/badge.variants.ts +++ b/unraid-ui/src/components/common/badge/badge.variants.ts @@ -1,4 +1,5 @@ -import { cva, VariantProps } from 'class-variance-authority'; +import type { VariantProps } from 'class-variance-authority'; +import { cva } from 'class-variance-authority'; export const badgeVariants = cva( 'inline-flex items-center rounded-full font-semibold leading-none transition-all duration-200 ease-in-out unraid-ui-badge-test', diff --git a/unraid-ui/src/components/common/button/button.variants.ts b/unraid-ui/src/components/common/button/button.variants.ts index 1fe127983..bd70ccb6b 100644 --- a/unraid-ui/src/components/common/button/button.variants.ts +++ b/unraid-ui/src/components/common/button/button.variants.ts @@ -1,4 +1,5 @@ -import { cva, VariantProps } from 'class-variance-authority'; +import type { VariantProps } from 'class-variance-authority'; +import { cva } from 'class-variance-authority'; export const buttonVariants = cva( 'inline-flex items-center justify-center rounded-md text-base font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', @@ -13,7 +14,7 @@ export const buttonVariants = cva( link: 'text-primary underline-offset-4 hover:underline', }, size: { - sm: 'h-9 rounded-md px-3', + sm: 'rounded-md px-3 py-1', md: 'h-10 px-4 py-2', lg: 'h-11 rounded-md px-8', icon: 'h-10 w-10', diff --git a/unraid-ui/src/components/common/dialog/Dialog.vue b/unraid-ui/src/components/common/dialog/Dialog.vue new file mode 100644 index 000000000..8735d7977 --- /dev/null +++ b/unraid-ui/src/components/common/dialog/Dialog.vue @@ -0,0 +1,15 @@ + + + diff --git a/unraid-ui/src/components/common/dialog/DialogClose.vue b/unraid-ui/src/components/common/dialog/DialogClose.vue new file mode 100644 index 000000000..6178f1044 --- /dev/null +++ b/unraid-ui/src/components/common/dialog/DialogClose.vue @@ -0,0 +1,14 @@ + + + diff --git a/unraid-ui/src/components/common/dialog/DialogContent.vue b/unraid-ui/src/components/common/dialog/DialogContent.vue new file mode 100644 index 000000000..6718b4f97 --- /dev/null +++ b/unraid-ui/src/components/common/dialog/DialogContent.vue @@ -0,0 +1,51 @@ + + + diff --git a/unraid-ui/src/components/common/dialog/DialogDescription.vue b/unraid-ui/src/components/common/dialog/DialogDescription.vue new file mode 100644 index 000000000..22a417d61 --- /dev/null +++ b/unraid-ui/src/components/common/dialog/DialogDescription.vue @@ -0,0 +1,18 @@ + + + diff --git a/unraid-ui/src/components/common/dialog/DialogFooter.vue b/unraid-ui/src/components/common/dialog/DialogFooter.vue new file mode 100644 index 000000000..4ee0475c1 --- /dev/null +++ b/unraid-ui/src/components/common/dialog/DialogFooter.vue @@ -0,0 +1,12 @@ + + + diff --git a/unraid-ui/src/components/common/dialog/DialogHeader.vue b/unraid-ui/src/components/common/dialog/DialogHeader.vue new file mode 100644 index 000000000..a13a3fcfc --- /dev/null +++ b/unraid-ui/src/components/common/dialog/DialogHeader.vue @@ -0,0 +1,14 @@ + + + diff --git a/unraid-ui/src/components/common/dialog/DialogScrollContent.vue b/unraid-ui/src/components/common/dialog/DialogScrollContent.vue new file mode 100644 index 000000000..e00928d58 --- /dev/null +++ b/unraid-ui/src/components/common/dialog/DialogScrollContent.vue @@ -0,0 +1,66 @@ + + + diff --git a/unraid-ui/src/components/common/dialog/DialogTitle.vue b/unraid-ui/src/components/common/dialog/DialogTitle.vue new file mode 100644 index 000000000..01f4262ed --- /dev/null +++ b/unraid-ui/src/components/common/dialog/DialogTitle.vue @@ -0,0 +1,21 @@ + + + diff --git a/unraid-ui/src/components/common/dialog/DialogTrigger.vue b/unraid-ui/src/components/common/dialog/DialogTrigger.vue new file mode 100644 index 000000000..152a211cf --- /dev/null +++ b/unraid-ui/src/components/common/dialog/DialogTrigger.vue @@ -0,0 +1,11 @@ + + + diff --git a/unraid-ui/src/components/common/dialog/index.ts b/unraid-ui/src/components/common/dialog/index.ts new file mode 100644 index 000000000..e924dd4b7 --- /dev/null +++ b/unraid-ui/src/components/common/dialog/index.ts @@ -0,0 +1,9 @@ +export { default as Dialog } from './Dialog.vue'; +export { default as DialogClose } from './DialogClose.vue'; +export { default as DialogContent } from './DialogContent.vue'; +export { default as DialogDescription } from './DialogDescription.vue'; +export { default as DialogFooter } from './DialogFooter.vue'; +export { default as DialogHeader } from './DialogHeader.vue'; +export { default as DialogScrollContent } from './DialogScrollContent.vue'; +export { default as DialogTitle } from './DialogTitle.vue'; +export { default as DialogTrigger } from './DialogTrigger.vue'; diff --git a/unraid-ui/src/components/common/sheet/sheet.variants.ts b/unraid-ui/src/components/common/sheet/sheet.variants.ts index 24465ec84..7dd3ce9ca 100644 --- a/unraid-ui/src/components/common/sheet/sheet.variants.ts +++ b/unraid-ui/src/components/common/sheet/sheet.variants.ts @@ -1,4 +1,5 @@ -import { cva, VariantProps } from 'class-variance-authority'; +import type { VariantProps } from 'class-variance-authority'; +import { cva } from 'class-variance-authority'; export const sheetVariants = cva( 'fixed z-50 bg-background gap-4 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500 border-border', diff --git a/unraid-ui/src/types/badge.ts b/unraid-ui/src/types/badge.ts index bbf915d4b..970cec0c0 100644 --- a/unraid-ui/src/types/badge.ts +++ b/unraid-ui/src/types/badge.ts @@ -1,4 +1,4 @@ -import { badgeVariants } from '@/components/common/badge/badge.variants'; +import type { badgeVariants } from '@/components/common/badge/badge.variants'; import type { VariantProps } from 'class-variance-authority'; import type { Component } from 'vue'; diff --git a/web/__test__/store/modal.test.ts b/web/__test__/store/modal.test.ts index 474afcfc7..39ba20e04 100644 --- a/web/__test__/store/modal.test.ts +++ b/web/__test__/store/modal.test.ts @@ -3,15 +3,18 @@ */ import { createPinia, setActivePinia } from 'pinia'; +import { ref } from 'vue'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useModalStore } from '~/store/modal'; vi.mock('@vueuse/core', () => ({ - useToggle: vi.fn((value) => () => { - value.value = !value.value; - }), + useToggle: (initial: boolean) => { + const state = ref(initial) + const toggle = () => { state.value = !state.value } + return [state, toggle] + } })); describe('Modal Store', () => { diff --git a/web/__test__/store/unraidApiSettings.test.ts b/web/__test__/store/unraidApiSettings.test.ts deleted file mode 100644 index 6d6690814..000000000 --- a/web/__test__/store/unraidApiSettings.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * UnraidApiSettings store test coverage - */ - -import { createPinia, setActivePinia } from 'pinia'; - -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { WanAccessType, WanForwardType } from '~/composables/gql/graphql'; -import { useUnraidApiSettingsStore } from '~/store/unraidApiSettings'; - -const mockOrigins = ['http://example.com']; -const mockRemoteAccess = { - accessType: WanAccessType.ALWAYS, - forwardType: WanForwardType.UPNP, - port: 8080, -}; - -const mockLoadFn = vi.fn(); -const mockMutateFn = vi.fn(); - -vi.mock('@vue/apollo-composable', () => ({ - useLazyQuery: () => ({ - load: mockLoadFn, - result: { - value: { - extraAllowedOrigins: mockOrigins, - remoteAccess: mockRemoteAccess, - }, - }, - }), - useMutation: () => ({ - mutate: mockMutateFn.mockImplementation((args) => { - if (args?.input?.origins) { - return Promise.resolve({ - data: { - setAdditionalAllowedOrigins: args.input.origins, - }, - }); - } - return Promise.resolve({ - data: { - setupRemoteAccess: true, - }, - }); - }), - }), -})); - -describe('UnraidApiSettings Store', () => { - let store: ReturnType; - - beforeEach(() => { - setActivePinia(createPinia()); - store = useUnraidApiSettingsStore(); - vi.clearAllMocks(); - }); - - describe('getAllowedOrigins', () => { - it('should get origins successfully', async () => { - const origins = await store.getAllowedOrigins(); - - expect(mockLoadFn).toHaveBeenCalled(); - expect(Array.isArray(origins)).toBe(true); - expect(origins).toEqual(mockOrigins); - }); - }); - - describe('setAllowedOrigins', () => { - it('should set origins and return the updated list of allowed origins', async () => { - const newOrigins = ['http://example.com', 'http://test.com']; - const result = await store.setAllowedOrigins(newOrigins); - - expect(mockMutateFn).toHaveBeenCalledWith({ - input: { origins: newOrigins }, - }); - expect(result).toEqual(newOrigins); - }); - }); - - describe('getRemoteAccess', () => { - it('should get remote access configuration successfully', async () => { - const result = await store.getRemoteAccess(); - - expect(mockLoadFn).toHaveBeenCalled(); - expect(result).toBeDefined(); - - if (result) { - expect(result).toEqual(mockRemoteAccess); - expect(result.accessType).toBe(WanAccessType.ALWAYS); - expect(result.forwardType).toBe(WanForwardType.UPNP); - expect(result.port).toBe(8080); - } - }); - }); - - describe('setupRemoteAccess', () => { - it('should setup remote access successfully and return true', async () => { - const input = { - accessType: WanAccessType.ALWAYS, - forwardType: WanForwardType.STATIC, - port: 9090, - }; - - const result = await store.setupRemoteAccess(input); - - expect(mockMutateFn).toHaveBeenCalledWith({ input }); - expect(result).toBe(true); - }); - }); -}); diff --git a/web/components/ApiKey/ApiKeyCreate.vue b/web/components/ApiKey/ApiKeyCreate.vue index 32331b3b7..374a4b511 100644 --- a/web/components/ApiKey/ApiKeyCreate.vue +++ b/web/components/ApiKey/ApiKeyCreate.vue @@ -1,12 +1,20 @@ + \ No newline at end of file + + + + {{ editingKey ? t('Edit API Key') : t('Create API Key') }} + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + + + + +
+ +
+
+
+
+ {{ perm.resource }} + +
+
+ +
+
+
+
+
+
+
+
+ {{ extractGraphQLErrorMessage(error) }} +
+
+
+ + + + +
+
+ diff --git a/web/components/ApiKey/ApiKeyManager.vue b/web/components/ApiKey/ApiKeyManager.vue index ce06a4499..9aeb32976 100644 --- a/web/components/ApiKey/ApiKeyManager.vue +++ b/web/components/ApiKey/ApiKeyManager.vue @@ -1,31 +1,54 @@ + diff --git a/web/components/ApiKey/PermissionCounter.vue b/web/components/ApiKey/PermissionCounter.vue new file mode 100644 index 000000000..8f7b99860 --- /dev/null +++ b/web/components/ApiKey/PermissionCounter.vue @@ -0,0 +1,59 @@ + + diff --git a/web/components/ApiKey/actionVariant.ts b/web/components/ApiKey/actionVariant.ts new file mode 100644 index 000000000..685f823e3 --- /dev/null +++ b/web/components/ApiKey/actionVariant.ts @@ -0,0 +1,30 @@ +export function actionVariant(action: string): + | 'black' + | 'gray' + | 'white' + | 'custom' + | 'red' + | 'yellow' + | 'green' + | 'blue' + | 'indigo' + | 'purple' + | 'pink' + | 'orange' + | 'transparent' + | 'current' + | null + | undefined { + switch (action) { + case 'read': + return 'blue'; + case 'create': + return 'green'; + case 'update': + return 'yellow'; + case 'delete': + return 'pink'; + default: + return 'gray'; + } +} \ No newline at end of file diff --git a/web/components/ApiKey/apikey.query.ts b/web/components/ApiKey/apikey.query.ts index db441eef0..8fd2cc5f6 100644 --- a/web/components/ApiKey/apikey.query.ts +++ b/web/components/ApiKey/apikey.query.ts @@ -1,18 +1,38 @@ import { graphql } from '~/composables/gql/gql'; +export const API_KEY_FRAGMENT = graphql(/* GraphQL */ ` + fragment ApiKey on ApiKey { + id + name + description + createdAt + roles + permissions { + resource + actions + } + } +`); + +export const API_KEY_FRAGMENT_WITH_KEY = graphql(/* GraphQL */ ` + fragment ApiKeyWithKey on ApiKeyWithSecret { + id + key + name + description + createdAt + roles + permissions { + resource + actions + } + } +`); export const GET_API_KEYS = graphql(/* GraphQL */ ` query ApiKeys { apiKeys { - id - name - description - createdAt - roles - permissions { - resource - actions - } + ...ApiKey } } `); @@ -21,16 +41,17 @@ export const CREATE_API_KEY = graphql(/* GraphQL */ ` mutation CreateApiKey($input: CreateApiKeyInput!) { apiKey { create(input: $input) { - id - key - name - description - createdAt - roles - permissions { - resource - actions - } + ...ApiKeyWithKey + } + } + } +`); + +export const UPDATE_API_KEY = graphql(/* GraphQL */ ` + mutation UpdateApiKey($input: UpdateApiKeyInput!) { + apiKey { + update(input: $input) { + ...ApiKeyWithKey } } } diff --git a/web/components/ApiKeyPage.ce.vue b/web/components/ApiKeyPage.ce.vue new file mode 100644 index 000000000..66b1ef8d7 --- /dev/null +++ b/web/components/ApiKeyPage.ce.vue @@ -0,0 +1,11 @@ + + + diff --git a/web/components/ConnectSettings/AllowedOrigins.vue b/web/components/ConnectSettings/AllowedOrigins.vue deleted file mode 100644 index dd28606d1..000000000 --- a/web/components/ConnectSettings/AllowedOrigins.vue +++ /dev/null @@ -1,51 +0,0 @@ - - - diff --git a/web/components/ConnectSettings/RemoteAccess.vue b/web/components/ConnectSettings/RemoteAccess.vue deleted file mode 100644 index 948cfba62..000000000 --- a/web/components/ConnectSettings/RemoteAccess.vue +++ /dev/null @@ -1,60 +0,0 @@ - - - diff --git a/web/components/Modals.ce.vue b/web/components/Modals.ce.vue index bb51670d0..1e4c0fec9 100644 --- a/web/components/Modals.ce.vue +++ b/web/components/Modals.ce.vue @@ -5,12 +5,15 @@ import { storeToRefs } from 'pinia'; import { useCallbackActionsStore } from '~/store/callbackActions'; import { useTrialStore } from '~/store/trial'; import { useUpdateOsStore } from '~/store/updateOs'; +import { useApiKeyStore } from '~/store/apiKey'; +import ApiKeyCreate from '~/components/ApiKey/ApiKeyCreate.vue'; const { t } = useI18n(); const { callbackStatus } = storeToRefs(useCallbackActionsStore()); const { trialModalVisible } = storeToRefs(useTrialStore()); const { updateOsModalVisible, changelogModalVisible } = storeToRefs(useUpdateOsStore()); +const { modalVisible: apiKeyModalVisible } = storeToRefs(useApiKeyStore()); diff --git a/web/composables/gql/gql.ts b/web/composables/gql/gql.ts index 6e0514ee0..6c01786a1 100644 --- a/web/composables/gql/gql.ts +++ b/web/composables/gql/gql.ts @@ -16,8 +16,11 @@ import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document- type Documents = { "\n query PartnerInfo {\n publicPartnerInfo {\n hasPartnerLogo\n partnerName\n partnerUrl\n partnerLogoUrl\n }\n }\n": typeof types.PartnerInfoDocument, "\n query ActivationCode {\n vars {\n regState\n }\n customization {\n activationCode {\n code\n partnerName\n serverName\n sysModel\n comment\n header\n headermetacolor\n background\n showBannerGradient\n theme\n }\n partnerInfo {\n hasPartnerLogo\n partnerName\n partnerUrl\n partnerLogoUrl\n }\n }\n }\n": typeof types.ActivationCodeDocument, - "\n query ApiKeys {\n apiKeys {\n id\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n }\n": typeof types.ApiKeysDocument, - "\n mutation CreateApiKey($input: CreateApiKeyInput!) {\n apiKey {\n create(input: $input) {\n id\n key\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n }\n }\n": typeof types.CreateApiKeyDocument, + "\n fragment ApiKey on ApiKey {\n id\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n": typeof types.ApiKeyFragmentDoc, + "\n fragment ApiKeyWithKey on ApiKeyWithSecret {\n id\n key\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n": typeof types.ApiKeyWithKeyFragmentDoc, + "\n query ApiKeys {\n apiKeys {\n ...ApiKey\n }\n }\n": typeof types.ApiKeysDocument, + "\n mutation CreateApiKey($input: CreateApiKeyInput!) {\n apiKey {\n create(input: $input) {\n ...ApiKeyWithKey\n }\n } \n }\n": typeof types.CreateApiKeyDocument, + "\n mutation UpdateApiKey($input: UpdateApiKeyInput!) {\n apiKey {\n update(input: $input) {\n ...ApiKeyWithKey\n }\n }\n }\n": typeof types.UpdateApiKeyDocument, "\n mutation DeleteApiKey($input: DeleteApiKeyInput!) {\n apiKey {\n delete(input: $input)\n }\n }\n": typeof types.DeleteApiKeyDocument, "\n query ApiKeyMeta {\n apiKeyPossibleRoles\n apiKeyPossiblePermissions {\n resource\n actions\n }\n }\n": typeof types.ApiKeyMetaDocument, "\n query Unified {\n settings {\n unified {\n id\n dataSchema\n uiSchema\n values\n }\n }\n }\n": typeof types.UnifiedDocument, @@ -45,16 +48,15 @@ type Documents = { "\n fragment PartialCloud on Cloud {\n error\n apiKey {\n valid\n error\n }\n cloud {\n status\n error\n }\n minigraphql {\n status\n error\n }\n relay {\n status\n error\n }\n }\n": typeof types.PartialCloudFragmentDoc, "\n query serverState {\n cloud {\n ...PartialCloud\n }\n config {\n error\n valid\n }\n info {\n os {\n hostname\n }\n }\n owner {\n avatar\n username\n }\n registration {\n state\n expiration\n keyFile {\n contents\n }\n updateExpiration\n }\n vars {\n regGen\n regState\n configError\n configValid\n }\n }\n": typeof types.ServerStateDocument, "\n query getTheme {\n publicTheme {\n name\n showBannerImage\n showBannerGradient\n headerBackgroundColor\n showHeaderDescription\n headerPrimaryTextColor\n headerSecondaryTextColor\n }\n }\n": typeof types.GetThemeDocument, - "\n query getExtraAllowedOrigins {\n extraAllowedOrigins\n }\n": typeof types.GetExtraAllowedOriginsDocument, - "\n query getRemoteAccess {\n remoteAccess {\n accessType\n forwardType\n port\n }\n }\n": typeof types.GetRemoteAccessDocument, - "\n mutation setAdditionalAllowedOrigins($input: AllowedOriginInput!) {\n setAdditionalAllowedOrigins(input: $input)\n }\n": typeof types.SetAdditionalAllowedOriginsDocument, - "\n mutation setupRemoteAccess($input: SetupRemoteAccessInput!) {\n setupRemoteAccess(input: $input)\n }\n": typeof types.SetupRemoteAccessDocument, }; const documents: Documents = { "\n query PartnerInfo {\n publicPartnerInfo {\n hasPartnerLogo\n partnerName\n partnerUrl\n partnerLogoUrl\n }\n }\n": types.PartnerInfoDocument, "\n query ActivationCode {\n vars {\n regState\n }\n customization {\n activationCode {\n code\n partnerName\n serverName\n sysModel\n comment\n header\n headermetacolor\n background\n showBannerGradient\n theme\n }\n partnerInfo {\n hasPartnerLogo\n partnerName\n partnerUrl\n partnerLogoUrl\n }\n }\n }\n": types.ActivationCodeDocument, - "\n query ApiKeys {\n apiKeys {\n id\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n }\n": types.ApiKeysDocument, - "\n mutation CreateApiKey($input: CreateApiKeyInput!) {\n apiKey {\n create(input: $input) {\n id\n key\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n }\n }\n": types.CreateApiKeyDocument, + "\n fragment ApiKey on ApiKey {\n id\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n": types.ApiKeyFragmentDoc, + "\n fragment ApiKeyWithKey on ApiKeyWithSecret {\n id\n key\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n": types.ApiKeyWithKeyFragmentDoc, + "\n query ApiKeys {\n apiKeys {\n ...ApiKey\n }\n }\n": types.ApiKeysDocument, + "\n mutation CreateApiKey($input: CreateApiKeyInput!) {\n apiKey {\n create(input: $input) {\n ...ApiKeyWithKey\n }\n } \n }\n": types.CreateApiKeyDocument, + "\n mutation UpdateApiKey($input: UpdateApiKeyInput!) {\n apiKey {\n update(input: $input) {\n ...ApiKeyWithKey\n }\n }\n }\n": types.UpdateApiKeyDocument, "\n mutation DeleteApiKey($input: DeleteApiKeyInput!) {\n apiKey {\n delete(input: $input)\n }\n }\n": types.DeleteApiKeyDocument, "\n query ApiKeyMeta {\n apiKeyPossibleRoles\n apiKeyPossiblePermissions {\n resource\n actions\n }\n }\n": types.ApiKeyMetaDocument, "\n query Unified {\n settings {\n unified {\n id\n dataSchema\n uiSchema\n values\n }\n }\n }\n": types.UnifiedDocument, @@ -82,10 +84,6 @@ const documents: Documents = { "\n fragment PartialCloud on Cloud {\n error\n apiKey {\n valid\n error\n }\n cloud {\n status\n error\n }\n minigraphql {\n status\n error\n }\n relay {\n status\n error\n }\n }\n": types.PartialCloudFragmentDoc, "\n query serverState {\n cloud {\n ...PartialCloud\n }\n config {\n error\n valid\n }\n info {\n os {\n hostname\n }\n }\n owner {\n avatar\n username\n }\n registration {\n state\n expiration\n keyFile {\n contents\n }\n updateExpiration\n }\n vars {\n regGen\n regState\n configError\n configValid\n }\n }\n": types.ServerStateDocument, "\n query getTheme {\n publicTheme {\n name\n showBannerImage\n showBannerGradient\n headerBackgroundColor\n showHeaderDescription\n headerPrimaryTextColor\n headerSecondaryTextColor\n }\n }\n": types.GetThemeDocument, - "\n query getExtraAllowedOrigins {\n extraAllowedOrigins\n }\n": types.GetExtraAllowedOriginsDocument, - "\n query getRemoteAccess {\n remoteAccess {\n accessType\n forwardType\n port\n }\n }\n": types.GetRemoteAccessDocument, - "\n mutation setAdditionalAllowedOrigins($input: AllowedOriginInput!) {\n setAdditionalAllowedOrigins(input: $input)\n }\n": types.SetAdditionalAllowedOriginsDocument, - "\n mutation setupRemoteAccess($input: SetupRemoteAccessInput!) {\n setupRemoteAccess(input: $input)\n }\n": types.SetupRemoteAccessDocument, }; /** @@ -113,11 +111,23 @@ export function graphql(source: "\n query ActivationCode {\n vars {\n r /** * 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 ApiKeys {\n apiKeys {\n id\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n }\n"): (typeof documents)["\n query ApiKeys {\n apiKeys {\n id\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n }\n"]; +export function graphql(source: "\n fragment ApiKey on ApiKey {\n id\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n"): (typeof documents)["\n fragment ApiKey on ApiKey {\n id\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\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 CreateApiKey($input: CreateApiKeyInput!) {\n apiKey {\n create(input: $input) {\n id\n key\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n }\n }\n"): (typeof documents)["\n mutation CreateApiKey($input: CreateApiKeyInput!) {\n apiKey {\n create(input: $input) {\n id\n key\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n }\n }\n"]; +export function graphql(source: "\n fragment ApiKeyWithKey on ApiKeyWithSecret {\n id\n key\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n"): (typeof documents)["\n fragment ApiKeyWithKey on ApiKeyWithSecret {\n id\n key\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\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 query ApiKeys {\n apiKeys {\n ...ApiKey\n }\n }\n"): (typeof documents)["\n query ApiKeys {\n apiKeys {\n ...ApiKey\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 CreateApiKey($input: CreateApiKeyInput!) {\n apiKey {\n create(input: $input) {\n ...ApiKeyWithKey\n }\n } \n }\n"): (typeof documents)["\n mutation CreateApiKey($input: CreateApiKeyInput!) {\n apiKey {\n create(input: $input) {\n ...ApiKeyWithKey\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 UpdateApiKey($input: UpdateApiKeyInput!) {\n apiKey {\n update(input: $input) {\n ...ApiKeyWithKey\n }\n }\n }\n"): (typeof documents)["\n mutation UpdateApiKey($input: UpdateApiKeyInput!) {\n apiKey {\n update(input: $input) {\n ...ApiKeyWithKey\n }\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -226,22 +236,6 @@ export function graphql(source: "\n query serverState {\n cloud {\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 query getTheme {\n publicTheme {\n name\n showBannerImage\n showBannerGradient\n headerBackgroundColor\n showHeaderDescription\n headerPrimaryTextColor\n headerSecondaryTextColor\n }\n }\n"): (typeof documents)["\n query getTheme {\n publicTheme {\n name\n showBannerImage\n showBannerGradient\n headerBackgroundColor\n showHeaderDescription\n headerPrimaryTextColor\n headerSecondaryTextColor\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 query getExtraAllowedOrigins {\n extraAllowedOrigins\n }\n"): (typeof documents)["\n query getExtraAllowedOrigins {\n extraAllowedOrigins\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 query getRemoteAccess {\n remoteAccess {\n accessType\n forwardType\n port\n }\n }\n"): (typeof documents)["\n query getRemoteAccess {\n remoteAccess {\n accessType\n forwardType\n port\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 setAdditionalAllowedOrigins($input: AllowedOriginInput!) {\n setAdditionalAllowedOrigins(input: $input)\n }\n"): (typeof documents)["\n mutation setAdditionalAllowedOrigins($input: AllowedOriginInput!) {\n setAdditionalAllowedOrigins(input: $input)\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 setupRemoteAccess($input: SetupRemoteAccessInput!) {\n setupRemoteAccess(input: $input)\n }\n"): (typeof documents)["\n mutation setupRemoteAccess($input: SetupRemoteAccessInput!) {\n setupRemoteAccess(input: $input)\n }\n"]; export function graphql(source: string) { return (documents as any)[source] ?? {}; diff --git a/web/composables/gql/graphql.ts b/web/composables/gql/graphql.ts index e217c2eca..61e7c920a 100644 --- a/web/composables/gql/graphql.ts +++ b/web/composables/gql/graphql.ts @@ -129,14 +129,10 @@ export type AddRoleForApiKeyInput = { role: Role; }; -export type AllowedOriginInput = { - /** A list of origins allowed to interact with the API */ - origins: Array; -}; - export type ApiConfig = { __typename?: 'ApiConfig'; extraOrigins: Array; + plugins: Array; sandbox?: Maybe; ssoSubIds: Array; version: Scalars['String']['output']; @@ -163,6 +159,8 @@ export type ApiKeyMutations = { delete: Scalars['Boolean']['output']; /** Remove a role from an API key */ removeRole: Scalars['Boolean']['output']; + /** Update an API key */ + update: ApiKeyWithSecret; }; @@ -189,6 +187,12 @@ export type ApiKeyMutationsRemoveRoleArgs = { input: RemoveRoleFromApiKeyInput; }; + +/** API Key related mutations */ +export type ApiKeyMutationsUpdateArgs = { + input: UpdateApiKeyInput; +}; + export type ApiKeyResponse = { __typename?: 'ApiKeyResponse'; error?: Maybe; @@ -206,21 +210,6 @@ export type ApiKeyWithSecret = Node & { roles: Array; }; -export type ApiSettingsInput = { - /** The type of WAN access to use for Remote Access */ - accessType?: InputMaybe; - /** A list of origins allowed to interact with the API */ - extraOrigins?: InputMaybe>; - /** The type of port forwarding to use for Remote Access */ - forwardType?: InputMaybe; - /** 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?: InputMaybe; - /** 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?: InputMaybe; - /** A list of Unique Unraid Account ID's */ - ssoUserIds?: InputMaybe>; -}; - export type ArrayCapacity = { __typename?: 'ArrayCapacity'; /** Capacity in number of disks */ @@ -478,20 +467,23 @@ export type ConnectSettings = Node & { values: ConnectSettingsValues; }; +export type ConnectSettingsInput = { + /** The type of WAN access to use for Remote Access */ + accessType?: InputMaybe; + /** The type of port forwarding to use for Remote Access */ + forwardType?: InputMaybe; + /** 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?: InputMaybe; +}; + export type ConnectSettingsValues = { __typename?: 'ConnectSettingsValues'; /** The type of WAN access used for Remote Access */ accessType: WanAccessType; - /** A list of origins allowed to interact with the API */ - extraOrigins: Array; /** The type of port forwarding used for Remote Access */ forwardType?: Maybe; /** The port used for Remote Access */ port?: Maybe; - /** 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: Scalars['Boolean']['output']; - /** A list of Unique Unraid Account ID's */ - ssoUserIds: Array; }; export type ConnectSignInInput = { @@ -942,6 +934,8 @@ export type MinigraphqlResponse = { export type Mutation = { __typename?: 'Mutation'; + /** Add one or more plugins to the API. Returns false if restart was triggered automatically, true if manual restart is required. */ + addPlugin: Scalars['Boolean']['output']; apiKey: ApiKeyMutations; archiveAll: NotificationOverview; /** Marks a notification as archived. */ @@ -963,7 +957,8 @@ export type Mutation = { rclone: RCloneMutations; /** Reads each notification to recompute & update the overview. */ recalculateOverview: NotificationOverview; - setAdditionalAllowedOrigins: Array; + /** Remove one or more plugins from the API. Returns false if restart was triggered automatically, true if manual restart is required. */ + removePlugin: Scalars['Boolean']['output']; setupRemoteAccess: Scalars['Boolean']['output']; unarchiveAll: NotificationOverview; unarchiveNotifications: NotificationOverview; @@ -975,6 +970,11 @@ export type Mutation = { }; +export type MutationAddPluginArgs = { + input: PluginManagementInput; +}; + + export type MutationArchiveAllArgs = { importance?: InputMaybe; }; @@ -1016,8 +1016,8 @@ export type MutationInitiateFlashBackupArgs = { }; -export type MutationSetAdditionalAllowedOriginsArgs = { - input: AllowedOriginInput; +export type MutationRemovePluginArgs = { + input: PluginManagementInput; }; @@ -1042,7 +1042,7 @@ export type MutationUnreadNotificationArgs = { export type MutationUpdateApiSettingsArgs = { - input: ApiSettingsInput; + input: ConnectSettingsInput; }; @@ -1212,6 +1212,27 @@ export type Permission = { resource: Resource; }; +export type Plugin = { + __typename?: 'Plugin'; + /** Whether the plugin has an API module */ + hasApiModule?: Maybe; + /** Whether the plugin has a CLI module */ + hasCliModule?: Maybe; + /** The name of the plugin package */ + name: Scalars['String']['output']; + /** The version of the plugin package */ + version: Scalars['String']['output']; +}; + +export type PluginManagementInput = { + /** Whether to treat plugins as bundled plugins. Bundled plugins are installed to node_modules at build time and controlled via config only. */ + bundled?: Scalars['Boolean']['input']; + /** Array of plugin package names to add or remove */ + names: Array; + /** Whether to restart the API after the operation. When false, a restart has already been queued. */ + restart?: Scalars['Boolean']['input']; +}; + export type ProfileModel = Node & { __typename?: 'ProfileModel'; avatar: Scalars['String']['output']; @@ -1247,7 +1268,6 @@ export type Query = { disks: Array; display: Display; docker: Docker; - extraAllowedOrigins: Array; flash: Flash; info: Info; logFile: LogFileContent; @@ -1259,6 +1279,8 @@ export type Query = { online: Scalars['Boolean']['output']; owner: Owner; parityHistory: Array; + /** List all installed plugins with their metadata */ + plugins: Array; publicPartnerInfo?: Maybe; publicTheme: Theme; rclone: RCloneBackupSettings; @@ -1637,6 +1659,14 @@ export type UnraidArray = Node & { state: ArrayState; }; +export type UpdateApiKeyInput = { + description?: InputMaybe; + id: Scalars['PrefixedID']['input']; + name?: InputMaybe; + permissions?: InputMaybe>; + roles?: InputMaybe>; +}; + export type UpdateSettingsResponse = { __typename?: 'UpdateSettingsResponse'; /** Whether a restart is required for the changes to take effect */ @@ -1983,17 +2013,37 @@ export type ActivationCodeQueryVariables = Exact<{ [key: string]: never; }>; export type ActivationCodeQuery = { __typename?: 'Query', vars: { __typename?: 'Vars', regState?: RegistrationState | null }, customization?: { __typename?: 'Customization', activationCode?: { __typename?: 'ActivationCode', code?: string | null, partnerName?: string | null, serverName?: string | null, sysModel?: string | null, comment?: string | null, header?: string | null, headermetacolor?: string | null, background?: string | null, showBannerGradient?: boolean | null, theme?: string | null } | null, partnerInfo?: { __typename?: 'PublicPartnerInfo', hasPartnerLogo: boolean, partnerName?: string | null, partnerUrl?: string | null, partnerLogoUrl?: string | null } | null } | null }; +export type ApiKeyFragment = { __typename?: 'ApiKey', id: string, name: string, description?: string | null, createdAt: string, roles: Array, permissions: Array<{ __typename?: 'Permission', resource: Resource, actions: Array }> } & { ' $fragmentName'?: 'ApiKeyFragment' }; + +export type ApiKeyWithKeyFragment = { __typename?: 'ApiKeyWithSecret', id: string, key: string, name: string, description?: string | null, createdAt: string, roles: Array, permissions: Array<{ __typename?: 'Permission', resource: Resource, actions: Array }> } & { ' $fragmentName'?: 'ApiKeyWithKeyFragment' }; + export type ApiKeysQueryVariables = Exact<{ [key: string]: never; }>; -export type ApiKeysQuery = { __typename?: 'Query', apiKeys: Array<{ __typename?: 'ApiKey', id: string, name: string, description?: string | null, createdAt: string, roles: Array, permissions: Array<{ __typename?: 'Permission', resource: Resource, actions: Array }> }> }; +export type ApiKeysQuery = { __typename?: 'Query', apiKeys: Array<( + { __typename?: 'ApiKey' } + & { ' $fragmentRefs'?: { 'ApiKeyFragment': ApiKeyFragment } } + )> }; export type CreateApiKeyMutationVariables = Exact<{ input: CreateApiKeyInput; }>; -export type CreateApiKeyMutation = { __typename?: 'Mutation', apiKey: { __typename?: 'ApiKeyMutations', create: { __typename?: 'ApiKeyWithSecret', id: string, key: string, name: string, description?: string | null, createdAt: string, roles: Array, permissions: Array<{ __typename?: 'Permission', resource: Resource, actions: Array }> } } }; +export type CreateApiKeyMutation = { __typename?: 'Mutation', apiKey: { __typename?: 'ApiKeyMutations', create: ( + { __typename?: 'ApiKeyWithSecret' } + & { ' $fragmentRefs'?: { 'ApiKeyWithKeyFragment': ApiKeyWithKeyFragment } } + ) } }; + +export type UpdateApiKeyMutationVariables = Exact<{ + input: UpdateApiKeyInput; +}>; + + +export type UpdateApiKeyMutation = { __typename?: 'Mutation', apiKey: { __typename?: 'ApiKeyMutations', update: ( + { __typename?: 'ApiKeyWithSecret' } + & { ' $fragmentRefs'?: { 'ApiKeyWithKeyFragment': ApiKeyWithKeyFragment } } + ) } }; export type DeleteApiKeyMutationVariables = Exact<{ input: DeleteApiKeyInput; @@ -2170,37 +2220,16 @@ export type GetThemeQueryVariables = Exact<{ [key: string]: never; }>; export type GetThemeQuery = { __typename?: 'Query', publicTheme: { __typename?: 'Theme', name: ThemeName, showBannerImage: boolean, showBannerGradient: boolean, headerBackgroundColor: string, showHeaderDescription: boolean, headerPrimaryTextColor: string, headerSecondaryTextColor?: string | null } }; -export type GetExtraAllowedOriginsQueryVariables = Exact<{ [key: string]: never; }>; - - -export type GetExtraAllowedOriginsQuery = { __typename?: 'Query', extraAllowedOrigins: Array }; - -export type GetRemoteAccessQueryVariables = Exact<{ [key: string]: never; }>; - - -export type GetRemoteAccessQuery = { __typename?: 'Query', remoteAccess: { __typename?: 'RemoteAccess', accessType: WanAccessType, forwardType?: WanForwardType | null, port?: number | null } }; - -export type SetAdditionalAllowedOriginsMutationVariables = Exact<{ - input: AllowedOriginInput; -}>; - - -export type SetAdditionalAllowedOriginsMutation = { __typename?: 'Mutation', setAdditionalAllowedOrigins: Array }; - -export type SetupRemoteAccessMutationVariables = Exact<{ - input: SetupRemoteAccessInput; -}>; - - -export type SetupRemoteAccessMutation = { __typename?: 'Mutation', setupRemoteAccess: boolean }; - +export const ApiKeyFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApiKey"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApiKey"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"roles"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resource"}},{"kind":"Field","name":{"kind":"Name","value":"actions"}}]}}]}}]} as unknown as DocumentNode; +export const ApiKeyWithKeyFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApiKeyWithKey"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApiKeyWithSecret"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"roles"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resource"}},{"kind":"Field","name":{"kind":"Name","value":"actions"}}]}}]}}]} as unknown as DocumentNode; export const NotificationFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NotificationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Notification"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"subject"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"importance"}},{"kind":"Field","name":{"kind":"Name","value":"link"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"formattedTimestamp"}}]}}]} as unknown as DocumentNode; export const NotificationCountFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NotificationCountFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NotificationCounts"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"total"}},{"kind":"Field","name":{"kind":"Name","value":"info"}},{"kind":"Field","name":{"kind":"Name","value":"warning"}},{"kind":"Field","name":{"kind":"Name","value":"alert"}}]}}]} as unknown as DocumentNode; export const PartialCloudFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PartialCloud"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Cloud"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"apiKey"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"valid"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"cloud"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"minigraphql"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"relay"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}}]}}]} as unknown as DocumentNode; export const PartnerInfoDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"PartnerInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"publicPartnerInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasPartnerLogo"}},{"kind":"Field","name":{"kind":"Name","value":"partnerName"}},{"kind":"Field","name":{"kind":"Name","value":"partnerUrl"}},{"kind":"Field","name":{"kind":"Name","value":"partnerLogoUrl"}}]}}]}}]} as unknown as DocumentNode; export const ActivationCodeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ActivationCode"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"vars"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"regState"}}]}},{"kind":"Field","name":{"kind":"Name","value":"customization"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activationCode"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"code"}},{"kind":"Field","name":{"kind":"Name","value":"partnerName"}},{"kind":"Field","name":{"kind":"Name","value":"serverName"}},{"kind":"Field","name":{"kind":"Name","value":"sysModel"}},{"kind":"Field","name":{"kind":"Name","value":"comment"}},{"kind":"Field","name":{"kind":"Name","value":"header"}},{"kind":"Field","name":{"kind":"Name","value":"headermetacolor"}},{"kind":"Field","name":{"kind":"Name","value":"background"}},{"kind":"Field","name":{"kind":"Name","value":"showBannerGradient"}},{"kind":"Field","name":{"kind":"Name","value":"theme"}}]}},{"kind":"Field","name":{"kind":"Name","value":"partnerInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasPartnerLogo"}},{"kind":"Field","name":{"kind":"Name","value":"partnerName"}},{"kind":"Field","name":{"kind":"Name","value":"partnerUrl"}},{"kind":"Field","name":{"kind":"Name","value":"partnerLogoUrl"}}]}}]}}]}}]} as unknown as DocumentNode; -export const ApiKeysDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ApiKeys"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apiKeys"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"roles"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resource"}},{"kind":"Field","name":{"kind":"Name","value":"actions"}}]}}]}}]}}]} as unknown as DocumentNode; -export const CreateApiKeyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateApiKey"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateApiKeyInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apiKey"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"create"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"roles"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resource"}},{"kind":"Field","name":{"kind":"Name","value":"actions"}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const ApiKeysDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ApiKeys"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apiKeys"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApiKey"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApiKey"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApiKey"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"roles"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resource"}},{"kind":"Field","name":{"kind":"Name","value":"actions"}}]}}]}}]} as unknown as DocumentNode; +export const CreateApiKeyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateApiKey"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateApiKeyInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apiKey"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"create"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApiKeyWithKey"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApiKeyWithKey"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApiKeyWithSecret"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"roles"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resource"}},{"kind":"Field","name":{"kind":"Name","value":"actions"}}]}}]}}]} as unknown as DocumentNode; +export const UpdateApiKeyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateApiKey"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateApiKeyInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apiKey"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"update"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApiKeyWithKey"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApiKeyWithKey"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApiKeyWithSecret"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"roles"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resource"}},{"kind":"Field","name":{"kind":"Name","value":"actions"}}]}}]}}]} as unknown as DocumentNode; export const DeleteApiKeyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteApiKey"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteApiKeyInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apiKey"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"delete"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]}}]} as unknown as DocumentNode; export const ApiKeyMetaDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ApiKeyMeta"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apiKeyPossibleRoles"}},{"kind":"Field","name":{"kind":"Name","value":"apiKeyPossiblePermissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resource"}},{"kind":"Field","name":{"kind":"Name","value":"actions"}}]}}]}}]} as unknown as DocumentNode; export const UnifiedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Unified"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"settings"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"unified"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"dataSchema"}},{"kind":"Field","name":{"kind":"Name","value":"uiSchema"}},{"kind":"Field","name":{"kind":"Name","value":"values"}}]}}]}}]}}]} as unknown as DocumentNode; @@ -2224,8 +2253,4 @@ export const ListRCloneRemotesDocument = {"kind":"Document","definitions":[{"kin export const ConnectSignInDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ConnectSignIn"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ConnectSignInInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connectSignIn"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode; export const SignOutDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SignOut"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connectSignOut"}}]}}]} as unknown as DocumentNode; export const ServerStateDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"serverState"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cloud"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PartialCloud"}}]}},{"kind":"Field","name":{"kind":"Name","value":"config"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"valid"}}]}},{"kind":"Field","name":{"kind":"Name","value":"info"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"os"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hostname"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"owner"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}},{"kind":"Field","name":{"kind":"Name","value":"registration"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"expiration"}},{"kind":"Field","name":{"kind":"Name","value":"keyFile"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contents"}}]}},{"kind":"Field","name":{"kind":"Name","value":"updateExpiration"}}]}},{"kind":"Field","name":{"kind":"Name","value":"vars"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"regGen"}},{"kind":"Field","name":{"kind":"Name","value":"regState"}},{"kind":"Field","name":{"kind":"Name","value":"configError"}},{"kind":"Field","name":{"kind":"Name","value":"configValid"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PartialCloud"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Cloud"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"apiKey"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"valid"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"cloud"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"minigraphql"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"relay"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}}]}}]} as unknown as DocumentNode; -export const GetThemeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"getTheme"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"publicTheme"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"showBannerImage"}},{"kind":"Field","name":{"kind":"Name","value":"showBannerGradient"}},{"kind":"Field","name":{"kind":"Name","value":"headerBackgroundColor"}},{"kind":"Field","name":{"kind":"Name","value":"showHeaderDescription"}},{"kind":"Field","name":{"kind":"Name","value":"headerPrimaryTextColor"}},{"kind":"Field","name":{"kind":"Name","value":"headerSecondaryTextColor"}}]}}]}}]} as unknown as DocumentNode; -export const GetExtraAllowedOriginsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"getExtraAllowedOrigins"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"extraAllowedOrigins"}}]}}]} as unknown as DocumentNode; -export const GetRemoteAccessDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"getRemoteAccess"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"remoteAccess"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"accessType"}},{"kind":"Field","name":{"kind":"Name","value":"forwardType"}},{"kind":"Field","name":{"kind":"Name","value":"port"}}]}}]}}]} as unknown as DocumentNode; -export const SetAdditionalAllowedOriginsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"setAdditionalAllowedOrigins"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AllowedOriginInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setAdditionalAllowedOrigins"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode; -export const SetupRemoteAccessDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"setupRemoteAccess"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SetupRemoteAccessInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setupRemoteAccess"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode; \ No newline at end of file +export const GetThemeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"getTheme"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"publicTheme"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"showBannerImage"}},{"kind":"Field","name":{"kind":"Name","value":"showBannerGradient"}},{"kind":"Field","name":{"kind":"Name","value":"headerBackgroundColor"}},{"kind":"Field","name":{"kind":"Name","value":"showHeaderDescription"}},{"kind":"Field","name":{"kind":"Name","value":"headerPrimaryTextColor"}},{"kind":"Field","name":{"kind":"Name","value":"headerSecondaryTextColor"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/web/helpers/functions.ts b/web/helpers/functions.ts index a6d67912f..1c939d06b 100644 --- a/web/helpers/functions.ts +++ b/web/helpers/functions.ts @@ -1,2 +1,21 @@ /** Output key + value as string for each item in the object. Adds new line after each item. */ -export const OBJ_TO_STR = (obj: object): string => Object.entries(obj).reduce((str, [p, val]) => `${str}${p}: ${val}\n`, ''); \ No newline at end of file +export const OBJ_TO_STR = (obj: object): string => Object.entries(obj).reduce((str, [p, val]) => `${str}${p}: ${val}\n`, ''); + +/** + * Extracts a meaningful error message from a GraphQL error or generic error object. + */ +export function extractGraphQLErrorMessage(err: unknown): string { + let message = 'An unknown error occurred.'; + const e = err as { graphQLErrors?: unknown; message?: string }; + const graphQLErrors = Array.isArray(e?.graphQLErrors) ? e.graphQLErrors : undefined; + if (graphQLErrors && graphQLErrors.length) { + const gqlError = graphQLErrors[0] as { extensions?: { originalError?: { message?: string[] } }; message?: string }; + message = + gqlError?.extensions?.originalError?.message?.[0] || + gqlError?.message || + message; + } else if (typeof err === 'object' && err !== null && 'message' in err && typeof e.message === 'string') { + message = e.message; + } + return message; +} \ No newline at end of file diff --git a/web/nuxt.config.ts b/web/nuxt.config.ts index 2abdc4d45..d001c9fad 100644 --- a/web/nuxt.config.ts +++ b/web/nuxt.config.ts @@ -156,6 +156,10 @@ export default defineNuxtConfig({ name: 'UnraidThemeSwitcher', path: '@/components/ThemeSwitcher.ce', }, + { + name: 'UnraidApiKeyManager', + path: '@/components/ApiKeyPage.ce', + }, ], }, ], diff --git a/web/pages/apikey.vue b/web/pages/apikey.vue index 573afe171..8ede0a61b 100644 --- a/web/pages/apikey.vue +++ b/web/pages/apikey.vue @@ -3,8 +3,5 @@ import ApiKeyManager from '~/components/ApiKey/ApiKeyManager.vue'; diff --git a/web/pages/index.vue b/web/pages/index.vue index e09108302..cf0df5a89 100644 --- a/web/pages/index.vue +++ b/web/pages/index.vue @@ -9,6 +9,8 @@ import type { SendPayloads } from '@unraid/shared-callbacks'; import LogViewerCe from '~/components/Logs/LogViewer.ce.vue'; import SsoButtonCe from '~/components/SsoButton.ce.vue'; import { useThemeStore } from '~/store/theme'; +import ModalsCe from '~/components/Modals.ce.vue'; +import ConnectSettingsCe from '~/components/ConnectSettings/ConnectSettings.ce.vue'; const serverStore = useDummyServerStore(); const { serverState } = storeToRefs(serverStore); diff --git a/web/pages/webComponents.vue b/web/pages/webComponents.vue index 3ac54b4e0..b5efa4e49 100644 --- a/web/pages/webComponents.vue +++ b/web/pages/webComponents.vue @@ -58,8 +58,8 @@ onBeforeMount(() => {

SSOSignInButtonCe


-

ActivationCodeCe

- +

ApiKeyManagerCe

+ diff --git a/web/store/apiKey.ts b/web/store/apiKey.ts new file mode 100644 index 000000000..11ede8e3a --- /dev/null +++ b/web/store/apiKey.ts @@ -0,0 +1,40 @@ +import { ref } from 'vue'; +import { createPinia, defineStore, setActivePinia } from 'pinia'; + +import type { ApiKeyFragment, ApiKeyWithKeyFragment } from '~/composables/gql/graphql'; + +setActivePinia(createPinia()); + +export const useApiKeyStore = defineStore('apiKey', () => { + const modalVisible = ref(false); + const editingKey = ref(null); + const createdKey = ref(null); + + function showModal(key: ApiKeyFragment | null = null) { + editingKey.value = key; + modalVisible.value = true; + } + + function hideModal() { + modalVisible.value = false; + editingKey.value = null; + } + + function setCreatedKey(key: ApiKeyWithKeyFragment | null) { + createdKey.value = key; + } + + function clearCreatedKey() { + createdKey.value = null; + } + + return { + modalVisible, + editingKey, + createdKey, + showModal, + hideModal, + setCreatedKey, + clearCreatedKey, + }; +}); diff --git a/web/store/modal.ts b/web/store/modal.ts index 8895fb8df..1500fc76c 100644 --- a/web/store/modal.ts +++ b/web/store/modal.ts @@ -1,4 +1,3 @@ -import { ref } from 'vue'; import { createPinia, defineStore, setActivePinia } from 'pinia'; import { useToggle } from '@vueuse/core'; @@ -9,7 +8,7 @@ import { useToggle } from '@vueuse/core'; setActivePinia(createPinia()); export const useModalStore = defineStore('modal', () => { - const modalVisible = ref(true); + const [modalVisible, modalToggle] = useToggle(true); const modalHide = () => { modalVisible.value = false; @@ -17,7 +16,6 @@ export const useModalStore = defineStore('modal', () => { const modalShow = () => { modalVisible.value = true; }; - const modalToggle = useToggle(modalVisible); return { modalVisible, diff --git a/web/store/unraidApiSettings.fragment.ts b/web/store/unraidApiSettings.fragment.ts deleted file mode 100644 index 821846c4c..000000000 --- a/web/store/unraidApiSettings.fragment.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { graphql } from '~/composables/gql/gql'; - -export const GET_ALLOWED_ORIGINS = graphql(/* GraphQL */ ` - query getExtraAllowedOrigins { - extraAllowedOrigins - } -`); - -export const GET_REMOTE_ACCESS = graphql(/* GraphQL */ ` - query getRemoteAccess { - remoteAccess { - accessType - forwardType - port - } - } -`); - -export const SET_ADDITIONAL_ALLOWED_ORIGINS = graphql(/* GraphQL */ ` - mutation setAdditionalAllowedOrigins($input: AllowedOriginInput!) { - setAdditionalAllowedOrigins(input: $input) - } -`); - -export const SETUP_REMOTE_ACCESS = graphql(/* GraphQL */ ` - mutation setupRemoteAccess($input: SetupRemoteAccessInput!) { - setupRemoteAccess(input: $input) - } -`); diff --git a/web/store/unraidApiSettings.ts b/web/store/unraidApiSettings.ts deleted file mode 100644 index d21296072..000000000 --- a/web/store/unraidApiSettings.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { defineStore } from 'pinia'; -import { useLazyQuery, useMutation } from '@vue/apollo-composable'; - -import type { SetupRemoteAccessInput } from '~/composables/gql/graphql'; - -import { - GET_ALLOWED_ORIGINS, - GET_REMOTE_ACCESS, - SET_ADDITIONAL_ALLOWED_ORIGINS, - SETUP_REMOTE_ACCESS, -} from '~/store/unraidApiSettings.fragment'; - -export const useUnraidApiSettingsStore = defineStore('unraidApiSettings', () => { - const { load: loadOrigins, result: origins } = useLazyQuery(GET_ALLOWED_ORIGINS); - - const { mutate: mutateOrigins } = useMutation(SET_ADDITIONAL_ALLOWED_ORIGINS); - const { load: loadRemoteAccess, result: remoteAccessResult } = useLazyQuery(GET_REMOTE_ACCESS); - - const { mutate: setupRemoteAccessMutation } = useMutation(SETUP_REMOTE_ACCESS); - const getAllowedOrigins = async () => { - await loadOrigins(); - return origins?.value?.extraAllowedOrigins ?? []; - }; - - const setAllowedOrigins = async (origins: string[]) => { - const result = await mutateOrigins({ input: { origins } }); - return result?.data?.setAdditionalAllowedOrigins; - }; - - const getRemoteAccess = async () => { - await loadRemoteAccess(); - return remoteAccessResult?.value?.remoteAccess; - }; - - const setupRemoteAccess = async (input: SetupRemoteAccessInput) => { - const response = await setupRemoteAccessMutation({ input }); - return response?.data?.setupRemoteAccess; - }; - - return { - getAllowedOrigins, - setAllowedOrigins, - getRemoteAccess, - setupRemoteAccess, - }; -});