diff --git a/web/apps/app/app/settings/page.tsx b/web/apps/app/app/settings/page.tsx index 50b5666fbc..1a994028ee 100644 --- a/web/apps/app/app/settings/page.tsx +++ b/web/apps/app/app/settings/page.tsx @@ -1,383 +1,4 @@ -'use client'; - -import { ResponsiveContainer } from 'recharts'; -import React, { ComponentProps, ReactNode, useEffect, useState } from 'react'; -import dynamic from 'next/dynamic'; -import { getTimeZones } from '@vvo/tzdb'; -import { Typography } from '@signalco/ui-primitives/Typography'; -import { Stack } from '@signalco/ui-primitives/Stack'; -import { SelectItems } from '@signalco/ui-primitives/SelectItems'; -import { Row } from '@signalco/ui-primitives/Row'; -import { Divider } from '@signalco/ui-primitives/Divider'; -import { Container } from '@signalco/ui-primitives/Container'; -import { Loadable } from '@signalco/ui/Loadable'; -import { FilterList } from '@signalco/ui/FilterList'; -import { arraySum, humanizeNumber, objectWithKey } from '@signalco/js'; -import { isNonEmptyString, isNotNull, noError } from '@enterwell/react-form-validation'; -import { FormBuilderComponent, FormBuilderComponents } from '@enterwell/react-form-builder/lib/FormBuilderProvider/FormBuilderProvider.types'; -import { FormBuilder, FormBuilderProvider, FormItems, useFormField } from '@enterwell/react-form-builder'; -import { now } from '../../src/services/DateTimeProvider'; -import { ApiDevelopmentUrl, ApiProductionUrl, setSignalcoApiEndpoint, signalcoApiEndpoint } from '../../src/services/AppSettingsProvider'; -import useUserSetting from '../../src/hooks/useUserSetting'; -import useLocale, { availableLocales } from '../../src/hooks/useLocale'; -import useAllEntities from '../../src/hooks/signalco/entity/useAllEntities'; -import AppThemePicker from '../../components/settings/AppThemePicker'; -import LocationMapPicker from '../../components/forms/LocationMapPicker/LocationMapPicker'; -import generalFormComponents from '../../components/forms/generalFormComponents'; -import ApiBadge from '../../components/development/ApiBadge'; - -const DynamicGraph = dynamic(() => import('../../components/graphs/Graph'), { - loading: () => , -}); - -function SettingsItem(props: { children: ReactNode, label?: string | undefined }) { - return ( - - {props.label && {props.label}} - {props.children} - - ); -} - -function SelectTimeFormat({ value, label, onChange }: ComponentProps) { - const { t } = useLocale('App', 'Settings'); - return ( - - ); -} - -function SelectLanguage({ value, label, onChange }: ComponentProps) { - const locales = useLocale('App', 'Locales'); - return ( - onChange(v, { receiveEvent: false })} - items={availableLocales.map(l => ({ value: l, label: locales.t(l) }))} /> - ); -} - -const settingsFormComponents: FormBuilderComponents = { - fieldWrapper: (props) => , - wrapper: (props) => , - selectApiEndpoint: ({ value, onChange, label }) => ( - onChange(v)} - items={[ - { - value: ApiProductionUrl, - label: ( - - - {ApiProductionUrl} - - ) - }, - { - value: ApiDevelopmentUrl, - label: ( - - - {ApiDevelopmentUrl} - - ) - } - ]} - label={label} /> - ), - selectTimeFormat: (props) => , - selectTimeZone: ({ value, onChange, label }) => { - const timeZones = getTimeZones(); - return ( - ({ value: tz.name, label: tz.currentTimeFormat })) - ]} - label={label} /> - ); - }, - locationMap: (props) => , - language: (props) => , - appTheme: () => -}; - -const components = { ...generalFormComponents, ...settingsFormComponents }; - -type Usage = { - contactSet: number; - conduct: number; - process: number; - other: number; -} - -function LabeledValue({ value, unit, label }: { value: string | number, unit?: string, label: string }) { - return ( - - {label} - - - {typeof value === 'number' ? humanizeNumber(value) : value} - - {!!unit && {unit}} - - - ) -} - -function sumUsage(u: Partial | undefined) { - return u ? (u.other ?? 0) + (u.contactSet ?? 0) + (u.conduct ?? 0) + (u.process ?? 0) : 0; -} - -function UsageCurrent() { - const usersEntities = useAllEntities(6); - const userEntity = usersEntities.data?.at(0); - - const limit = 2000; - - const nowDate = now(); - const daysInCurrentMonth = new Date(nowDate.getFullYear(), nowDate.getMonth() - 1, 0).getDate(); - const usages = [...new Array(daysInCurrentMonth).keys()].map(d => { - const dayUsage = JSON.parse( - (userEntity?.contacts.find((c: unknown) => - objectWithKey(c, 'channelName')?.channelName === 'signalco' && - objectWithKey(c, 'channelName')?.contactName === `usage-${nowDate.getFullYear()}${(nowDate.getMonth() + 1).toString().padStart(2, '0')}${(d + 1).toString().padStart(2, '0')}`) - ?.valueSerialized) - ?? '{}'); - - return { - id: `${nowDate.getFullYear()}-${(nowDate.getMonth() + 1).toString().padStart(2, '0')}-${(d + 1).toString().padStart(2, '0')}`, - value: { - consuct: Number(objectWithKey(dayUsage, 'conduct')?.conduct) || 0, - contactSet: Number(objectWithKey(dayUsage, 'contactSet')?.contactSet) || 0, - process: Number(objectWithKey(dayUsage, 'process')?.process) || 0, - other: Number(objectWithKey(dayUsage, 'other')?.other) || 0 - } - }; - }); - - // Last 5 days (including current) - const calulatedUsageSlice = usages.slice(nowDate.getDate() - 5, nowDate.getDate()); - const usageTotal = arraySum(calulatedUsageSlice.map(s => s.value), sumUsage); - - const dailyCalculated = Math.round(usageTotal / calulatedUsageSlice.length); - const monthlyCalculated = usageTotal + dailyCalculated * daysInCurrentMonth; - - const predictedUsage = usages.map((u, i) => { - const isCurrent = i < nowDate.getDate(); - return isCurrent - ? u - : ({ - id: u.id, - value: { - conduct: 0, - contactSet: 0, - process: 0, - other: dailyCalculated - } - }); - }); - - return ( - - - - - - - - -
- - - -
-
- ) -} - -function UsagePlan() { - return ( - - - - - - - ) -} - -function UsagePage() { - return ( - - - Plan - - - - Current - - - {/* History */} - - ) -} - -type SettingsCategory = { - id: string; - label: string; - form?: FormItems; - component?: () => React.JSX.Element; -} - -function SettingsPane() { - const generalForm = useGeneralForm(); - const profileForm = useProfileForm(); - const lookAndFeelForm = useLookAndFeelForm(); - const timeLocationForm = useTimeLocationForm(); - const developerForm = useDeveloperForm(); - - const categories: SettingsCategory[] = [ - { id: 'general', label: 'General', form: generalForm }, - { id: 'lookAndFeel', label: 'Look and feel', form: lookAndFeelForm }, - { id: 'profile', label: 'Profile', form: profileForm }, - { id: 'timeAndLocation', label: 'Time and location', form: timeLocationForm }, - { id: 'usage', label: 'Usage', component: UsagePage }, - { id: 'developer', label: 'Developer', form: developerForm }, - ]; - - const [selectedCategoryId, setSelectedCategoryId] = useState(categories[0]?.id); - const selectedCategory = categories.find(c => c.id === selectedCategoryId) ?? categories[0]; - - return ( -
- - - - {selectedCategory?.label} -
- {selectedCategory?.form && ( - - - - )} - {selectedCategory?.component && } -
-
-
-
- ) -} - -function useLookAndFeelForm() { - const form: FormItems = { - appTheme: useFormField(undefined, noError, 'appTheme', 'App theme') - }; - - return form; -} - -function useGeneralForm() { - const [userLocale, setUserLocale] = useUserSetting('locale', 'en'); - - const form: FormItems = { - language: useFormField(userLocale, noError, 'language', 'Language') - }; - - useEffect(() => { - if (!form.language?.error && - form.language?.value !== userLocale) { - setUserLocale(form.language?.value); - window.location.reload(); - } - }, [form.language, setUserLocale, userLocale]); - - return form; -} - -function useProfileForm() { - const { t } = useLocale('App', 'Settings'); - const [userNickName, setUserNickName] = useUserSetting('nickname', ''); - - const profileForm: FormItems = { - nickname: useFormField(userNickName, isNonEmptyString, 'string', t('Nickname')), - } - - useEffect(() => { - if (!profileForm.nickname?.error) { - setUserNickName(profileForm.nickname?.value?.trim() || undefined); - } - }, [setUserNickName, profileForm.nickname]); - - return profileForm; -} - -function useDeveloperForm() { - const { t } = useLocale('App', 'Settings'); - const developerSettingsForm: FormItems = { - apiEndpoint: useFormField(signalcoApiEndpoint(), isNonEmptyString, 'selectApiEndpoint', t('ApiEndpoint'), { receiveEvent: false }) - }; - - useEffect(() => { - if (!developerSettingsForm.apiEndpoint?.error && - developerSettingsForm.apiEndpoint?.value !== signalcoApiEndpoint()) { - setSignalcoApiEndpoint(developerSettingsForm.apiEndpoint?.value); - window.location.reload(); - } - }, [developerSettingsForm.apiEndpoint]); - - return developerSettingsForm; -} - -function useTimeLocationForm() { - const { t } = useLocale('App', 'Settings'); - const [userTimeFormat, setUserTimeFormat] = useUserSetting('timeFormat', '1'); - const [userTimeZone, setUserTimeZone] = useUserSetting('timeZone', '0'); - const [userLocation, setUserLocation] = useUserSetting<[number, number] | undefined>('location', undefined); - - const timeLocationForm: FormItems = { - timeFormat: useFormField(userTimeFormat, isNotNull, 'selectTimeFormat', t('TimeFormat'), { receiveEvent: false }), - timeZone: useFormField(userTimeZone, isNotNull, 'selectTimeZone', t('TimeZone'), { receiveEvent: false }), - location: useFormField(userLocation, noError, 'locationMap', t('Location')) - }; - - useEffect(() => { - if (!timeLocationForm.timeFormat?.error) { - setUserTimeFormat(timeLocationForm.timeFormat?.value?.trim() || undefined); - } - }, [setUserTimeFormat, timeLocationForm.timeFormat]); - - useEffect(() => { - if (!timeLocationForm.timeZone?.error) { - setUserTimeZone(timeLocationForm.timeZone?.value?.trim() || undefined); - } - }, [setUserTimeZone, timeLocationForm.timeZone]); - - useEffect(() => { - if (!timeLocationForm.location?.error) { - setUserLocation(timeLocationForm.location?.value); - } - }, [setUserLocation, timeLocationForm.location]); - - return timeLocationForm; -} +import { SettingsPane } from '../../components/settings/SettingsPane'; export default function SettingsIndex() { return ( diff --git a/web/apps/app/components/settings/AuthPage.tsx b/web/apps/app/components/settings/AuthPage.tsx new file mode 100644 index 0000000000..eb84f37944 --- /dev/null +++ b/web/apps/app/components/settings/AuthPage.tsx @@ -0,0 +1,14 @@ +'use client'; +import React from 'react'; +import { Stack } from '@signalco/ui-primitives/Stack'; +import { CreateAuthPatButton } from '../../components/settings/CreateAuthPatButton'; +import { AuthPatsList } from '../../components/settings/AuthPatsList'; + +export function AuthPage() { + return ( + + + + + ); +} diff --git a/web/apps/app/components/settings/AuthPatsList.tsx b/web/apps/app/components/settings/AuthPatsList.tsx new file mode 100644 index 0000000000..a17a2afb1b --- /dev/null +++ b/web/apps/app/components/settings/AuthPatsList.tsx @@ -0,0 +1,30 @@ +import { Typography } from '@signalco/ui-primitives/Typography'; +import { Row } from '@signalco/ui-primitives/Row'; +import { ListItem } from '@signalco/ui-primitives/ListItem'; +import { List } from '@signalco/ui-primitives/List'; +import { Loadable } from '@signalco/ui/Loadable'; +import { useAllAuthPats } from '../../src/hooks/signalco/pats/useAuthPats'; + +export function AuthPatsList() { + const { data: pats, isLoading, error } = useAllAuthPats(); + + return ( + + + {pats?.map((pat) => ( + + {pat.alias} + {`****...****${pat.patEnd}`} + {pat.expire} + + )} /> + ))} + + + ); +} + + diff --git a/web/apps/app/components/settings/CreateAuthPatButton.tsx b/web/apps/app/components/settings/CreateAuthPatButton.tsx new file mode 100644 index 0000000000..a4799e59fd --- /dev/null +++ b/web/apps/app/components/settings/CreateAuthPatButton.tsx @@ -0,0 +1,70 @@ +import { useState } from 'react'; +import { Stack } from '@signalco/ui-primitives/Stack'; +import { Modal } from '@signalco/ui-primitives/Modal'; +import { Button } from '@signalco/ui-primitives/Button'; +import { showNotification } from '@signalco/ui-notifications'; +import { noError, submitForm, resetFields } from '@enterwell/react-form-validation'; +import { FormBuilder, useFormField } from '@enterwell/react-form-builder'; +import GeneralFormProvider from '../forms/GeneralFormProvider'; +import { useCreateAuthPat } from '../../src/hooks/signalco/pats/useCreateAuthPat'; + + +export function CreateAuthPatButton() { + const createPat = useCreateAuthPat(); + const [open, setOpen] = useState(false); + const [pat, setPat] = useState(); + + const form = { + alias: useFormField('', noError, 'string', 'Alias'), + expire: useFormField('', noError, 'dateTimeFuture', 'Expire'), + }; + + const handleOpen = () => { + setPat(undefined); + resetFields(form); + setOpen(true); + } + + const handleCreate = async (data: object) => { + try { + const pat = await createPat.mutateAsync(data); + setPat(pat?.pat); + showNotification('PAT created', 'success'); + } catch (error) { + console.error(error); + showNotification('Error creating PAT', 'error'); + } + }; + + const handleClose = () => { + setOpen(false); + } + + return ( + newOpen ? handleOpen() : handleClose()} + trigger={( + + )}> + + {pat && ( + <> +

Here is your new PAT:

+
{pat}
+

Make sure to copy it now, as you won't be able to see it again.

+ + + )} + {!pat && ( + <> + + + + + + )} +
+
+ ); +} diff --git a/web/apps/app/components/settings/LabeledValue.tsx b/web/apps/app/components/settings/LabeledValue.tsx new file mode 100644 index 0000000000..ca788d18c8 --- /dev/null +++ b/web/apps/app/components/settings/LabeledValue.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { Typography } from '@signalco/ui-primitives/Typography'; +import { Stack } from '@signalco/ui-primitives/Stack'; +import { Row } from '@signalco/ui-primitives/Row'; +import { humanizeNumber } from '@signalco/js'; + +export function LabeledValue({ value, unit, label }: { value: string | number; unit?: string; label: string; }) { + return ( + + {label} + + + {typeof value === 'number' ? humanizeNumber(value) : value} + + {!!unit && {unit}} + + + ); +} diff --git a/web/apps/app/components/settings/SelectLanguage.tsx b/web/apps/app/components/settings/SelectLanguage.tsx new file mode 100644 index 0000000000..bf47e41ac0 --- /dev/null +++ b/web/apps/app/components/settings/SelectLanguage.tsx @@ -0,0 +1,17 @@ +'use client'; + +import React, { ComponentProps } from 'react'; +import { SelectItems } from '@signalco/ui-primitives/SelectItems'; +import { FormBuilderComponent } from '@enterwell/react-form-builder/lib/FormBuilderProvider/FormBuilderProvider.types'; +import useLocale, { availableLocales } from '../../src/hooks/useLocale'; + +export function SelectLanguage({ value, label, onChange }: ComponentProps) { + const locales = useLocale('App', 'Locales'); + return ( + onChange(v, { receiveEvent: false })} + items={availableLocales.map(l => ({ value: l, label: locales.t(l) }))} /> + ); +} diff --git a/web/apps/app/components/settings/SelectTimeFormat.tsx b/web/apps/app/components/settings/SelectTimeFormat.tsx new file mode 100644 index 0000000000..2f79e30812 --- /dev/null +++ b/web/apps/app/components/settings/SelectTimeFormat.tsx @@ -0,0 +1,20 @@ +'use client'; + +import React, { ComponentProps } from 'react'; +import { SelectItems } from '@signalco/ui-primitives/SelectItems'; +import { FormBuilderComponent } from '@enterwell/react-form-builder/lib/FormBuilderProvider/FormBuilderProvider.types'; +import useLocale from '../../src/hooks/useLocale'; + +export function SelectTimeFormat({ value, label, onChange }: ComponentProps) { + const { t } = useLocale('App', 'Settings'); + return ( + + ); +} diff --git a/web/apps/app/components/settings/SettingsItem.tsx b/web/apps/app/components/settings/SettingsItem.tsx new file mode 100644 index 0000000000..d5d4784cbf --- /dev/null +++ b/web/apps/app/components/settings/SettingsItem.tsx @@ -0,0 +1,12 @@ +import React, { ReactNode } from 'react'; +import { Typography } from '@signalco/ui-primitives/Typography'; +import { Stack } from '@signalco/ui-primitives/Stack'; + +export function SettingsItem(props: { children: ReactNode; label?: string | undefined; }) { + return ( + + {props.label && {props.label}} + {props.children} + + ); +} diff --git a/web/apps/app/components/settings/SettingsPane.tsx b/web/apps/app/components/settings/SettingsPane.tsx new file mode 100644 index 0000000000..3467d2909d --- /dev/null +++ b/web/apps/app/components/settings/SettingsPane.tsx @@ -0,0 +1,319 @@ +'use client'; + +import { ResponsiveContainer } from 'recharts'; +import React, { useEffect, useState } from 'react'; +import { getTimeZones } from '@vvo/tzdb'; +import { Typography } from '@signalco/ui-primitives/Typography'; +import { Stack } from '@signalco/ui-primitives/Stack'; +import { SelectItems } from '@signalco/ui-primitives/SelectItems'; +import { Row } from '@signalco/ui-primitives/Row'; +import { Divider } from '@signalco/ui-primitives/Divider'; +import { Container } from '@signalco/ui-primitives/Container'; +import { FilterList } from '@signalco/ui/FilterList'; +import { arraySum, objectWithKey } from '@signalco/js'; +import { isNonEmptyString, isNotNull, noError } from '@enterwell/react-form-validation'; +import { FormBuilderComponents } from '@enterwell/react-form-builder/lib/FormBuilderProvider/FormBuilderProvider.types'; +import { FormBuilder, FormBuilderProvider, FormItems, useFormField } from '@enterwell/react-form-builder'; +import Graph from '../graphs/Graph'; +import { now } from '../../src/services/DateTimeProvider'; +import { ApiDevelopmentUrl, ApiProductionUrl, setSignalcoApiEndpoint, signalcoApiEndpoint } from '../../src/services/AppSettingsProvider'; +import useUserSetting from '../../src/hooks/useUserSetting'; +import useLocale from '../../src/hooks/useLocale'; +import useAllEntities from '../../src/hooks/signalco/entity/useAllEntities'; +import AppThemePicker from '../../components/settings/AppThemePicker'; +import LocationMapPicker from '../../components/forms/LocationMapPicker/LocationMapPicker'; +import generalFormComponents from '../../components/forms/generalFormComponents'; +import ApiBadge from '../../components/development/ApiBadge'; +import { UsagePage } from './UsagePage'; +import { SettingsItem } from './SettingsItem'; +import { SelectTimeFormat } from './SelectTimeFormat'; +import { SelectLanguage } from './SelectLanguage'; +import { LabeledValue } from './LabeledValue'; +import { AuthPage } from './AuthPage'; + +const settingsFormComponents: FormBuilderComponents = { + fieldWrapper: (props) => , + wrapper: (props) => , + selectApiEndpoint: ({ value, onChange, label }) => ( + onChange(v)} + items={[ + { + value: ApiProductionUrl, + label: ( + + + {ApiProductionUrl} + + ) + }, + { + value: ApiDevelopmentUrl, + label: ( + + + {ApiDevelopmentUrl} + + ) + } + ]} + label={label} /> + ), + selectTimeFormat: (props) => , + selectTimeZone: ({ value, onChange, label }) => { + const timeZones = getTimeZones(); + return ( + ({ value: tz.name, label: tz.currentTimeFormat })) + ]} + label={label} /> + ); + }, + locationMap: (props) => , + language: (props) => , + appTheme: () => +}; + +const components = { ...generalFormComponents, ...settingsFormComponents }; + +type Usage = { + contactSet: number; + conduct: number; + process: number; + other: number; +} + +function sumUsage(u: Partial | undefined) { + return u ? (u.other ?? 0) + (u.contactSet ?? 0) + (u.conduct ?? 0) + (u.process ?? 0) : 0; +} + +export function UsageCurrent() { + const usersEntities = useAllEntities(6); + const userEntity = usersEntities.data?.at(0); + + const limit = 2000; + + const nowDate = now(); + const daysInCurrentMonth = new Date(nowDate.getFullYear(), nowDate.getMonth() - 1, 0).getDate(); + const usages = [...new Array(daysInCurrentMonth).keys()].map(d => { + const dayUsage = JSON.parse( + (userEntity?.contacts.find((c: unknown) => + objectWithKey(c, 'channelName')?.channelName === 'signalco' && + objectWithKey(c, 'channelName')?.contactName === `usage-${nowDate.getFullYear()}${(nowDate.getMonth() + 1).toString().padStart(2, '0')}${(d + 1).toString().padStart(2, '0')}`) + ?.valueSerialized) + ?? '{}'); + + return { + id: `${nowDate.getFullYear()}-${(nowDate.getMonth() + 1).toString().padStart(2, '0')}-${(d + 1).toString().padStart(2, '0')}`, + value: { + consuct: Number(objectWithKey(dayUsage, 'conduct')?.conduct) || 0, + contactSet: Number(objectWithKey(dayUsage, 'contactSet')?.contactSet) || 0, + process: Number(objectWithKey(dayUsage, 'process')?.process) || 0, + other: Number(objectWithKey(dayUsage, 'other')?.other) || 0 + } + }; + }); + + // Last 5 days (including current) + const calulatedUsageSlice = usages.slice(nowDate.getDate() - 5, nowDate.getDate()); + const usageTotal = arraySum(calulatedUsageSlice.map(s => s.value), sumUsage); + + const dailyCalculated = Math.round(usageTotal / calulatedUsageSlice.length); + const monthlyCalculated = usageTotal + dailyCalculated * daysInCurrentMonth; + + const predictedUsage = usages.map((u, i) => { + const isCurrent = i < nowDate.getDate(); + return isCurrent + ? u + : ({ + id: u.id, + value: { + conduct: 0, + contactSet: 0, + process: 0, + other: dailyCalculated + } + }); + }); + + return ( + + + + + + + + +
+ + + +
+
+ ) +} + +export function UsagePlan() { + return ( + + + + + + + ) +} + +type SettingsCategory = { + id: string; + label: string; + form?: FormItems; + component?: () => React.JSX.Element; +} + + +function useLookAndFeelForm() { + const form: FormItems = { + appTheme: useFormField(undefined, noError, 'appTheme', 'App theme') + }; + + return form; +} + +function useGeneralForm() { + const [userLocale, setUserLocale] = useUserSetting('locale', 'en'); + + const form: FormItems = { + language: useFormField(userLocale, noError, 'language', 'Language') + }; + + useEffect(() => { + if (!form.language?.error && + form.language?.value !== userLocale) { + setUserLocale(form.language?.value); + window.location.reload(); + } + }, [form.language, setUserLocale, userLocale]); + + return form; +} + +function useProfileForm() { + const { t } = useLocale('App', 'Settings'); + const [userNickName, setUserNickName] = useUserSetting('nickname', ''); + + const profileForm: FormItems = { + nickname: useFormField(userNickName, isNonEmptyString, 'string', t('Nickname')), + } + + useEffect(() => { + if (!profileForm.nickname?.error) { + setUserNickName(profileForm.nickname?.value?.trim() || undefined); + } + }, [setUserNickName, profileForm.nickname]); + + return profileForm; +} + +function useDeveloperForm() { + const { t } = useLocale('App', 'Settings'); + const developerSettingsForm: FormItems = { + apiEndpoint: useFormField(signalcoApiEndpoint(), isNonEmptyString, 'selectApiEndpoint', t('ApiEndpoint'), { receiveEvent: false }) + }; + + useEffect(() => { + if (!developerSettingsForm.apiEndpoint?.error && + developerSettingsForm.apiEndpoint?.value !== signalcoApiEndpoint()) { + setSignalcoApiEndpoint(developerSettingsForm.apiEndpoint?.value); + window.location.reload(); + } + }, [developerSettingsForm.apiEndpoint]); + + return developerSettingsForm; +} + +function useTimeLocationForm() { + const { t } = useLocale('App', 'Settings'); + const [userTimeFormat, setUserTimeFormat] = useUserSetting('timeFormat', '1'); + const [userTimeZone, setUserTimeZone] = useUserSetting('timeZone', '0'); + const [userLocation, setUserLocation] = useUserSetting<[number, number] | undefined>('location', undefined); + + const timeLocationForm: FormItems = { + timeFormat: useFormField(userTimeFormat, isNotNull, 'selectTimeFormat', t('TimeFormat'), { receiveEvent: false }), + timeZone: useFormField(userTimeZone, isNotNull, 'selectTimeZone', t('TimeZone'), { receiveEvent: false }), + location: useFormField(userLocation, noError, 'locationMap', t('Location')) + }; + + useEffect(() => { + if (!timeLocationForm.timeFormat?.error) { + setUserTimeFormat(timeLocationForm.timeFormat?.value?.trim() || undefined); + } + }, [setUserTimeFormat, timeLocationForm.timeFormat]); + + useEffect(() => { + if (!timeLocationForm.timeZone?.error) { + setUserTimeZone(timeLocationForm.timeZone?.value?.trim() || undefined); + } + }, [setUserTimeZone, timeLocationForm.timeZone]); + + useEffect(() => { + if (!timeLocationForm.location?.error) { + setUserLocation(timeLocationForm.location?.value); + } + }, [setUserLocation, timeLocationForm.location]); + + return timeLocationForm; +} + +export function SettingsPane() { + const generalForm = useGeneralForm(); + const profileForm = useProfileForm(); + const lookAndFeelForm = useLookAndFeelForm(); + const timeLocationForm = useTimeLocationForm(); + const developerForm = useDeveloperForm(); + + const categories: SettingsCategory[] = [ + { id: 'general', label: 'General', form: generalForm }, + { id: 'lookAndFeel', label: 'Look and feel', form: lookAndFeelForm }, + { id: 'profile', label: 'Profile', form: profileForm }, + { id: 'auth', label: 'Auth', component: AuthPage }, + { id: 'timeAndLocation', label: 'Time and location', form: timeLocationForm }, + { id: 'usage', label: 'Usage', component: UsagePage }, + { id: 'developer', label: 'Developer', form: developerForm }, + ]; + + const [selectedCategoryId, setSelectedCategoryId] = useState(categories[0]?.id); + const selectedCategory = categories.find(c => c.id === selectedCategoryId) ?? categories[0]; + + return ( +
+ + + + {selectedCategory?.label} +
+ {selectedCategory?.form && ( + + + + )} + {selectedCategory?.component && } +
+
+
+
+ ) +} diff --git a/web/apps/app/components/settings/UsagePage.tsx b/web/apps/app/components/settings/UsagePage.tsx new file mode 100644 index 0000000000..071f392ab4 --- /dev/null +++ b/web/apps/app/components/settings/UsagePage.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { Typography } from '@signalco/ui-primitives/Typography'; +import { Stack } from '@signalco/ui-primitives/Stack'; +import { UsagePlan, UsageCurrent } from './SettingsPane'; + +export function UsagePage() { + return ( + + + Plan + + + + Current + + + {/* History */} + + ); +} diff --git a/web/apps/app/package.json b/web/apps/app/package.json index 562e73c014..4a9ed11b22 100644 --- a/web/apps/app/package.json +++ b/web/apps/app/package.json @@ -9,6 +9,7 @@ "node": "20.x" }, "scripts": { + "prepare:api": "pnpm dlx openapi-typescript https://api.signalco.io/api/swagger.yaml -o ./src/api/signalco/schema.d.ts", "dev": "next -p 3001", "build": "next build", "build:analyze": "cross-env ANALYZE=true pnpm build", @@ -57,6 +58,8 @@ "next": "14.0.4", "next-secure-headers": "2.2.0", "next-themes": "0.2.1", + "openapi-typescript": "6.7.3", + "openapi-fetch": "0.8.2", "pigeon-maps": "0.21.3", "react": "18.2.0", "react-cool-inview": "3.0.1", diff --git a/web/apps/app/src/api/signalco/schema.d.ts b/web/apps/app/src/api/signalco/schema.d.ts new file mode 100644 index 0000000000..930a92343d --- /dev/null +++ b/web/apps/app/src/api/signalco/schema.d.ts @@ -0,0 +1,643 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + + +export interface paths { + "/status": { + get: operations["HealthFunctions"]; + }; + "/website/newsletter-subscribe": { + /** @description Subscribe to a newsletter. */ + post: operations["NewsletterFunction"]; + }; + "/stations/logging/download": { + get: operations["StationLoggingDownloadFunction"]; + }; + "/station/logging/list": { + get: operations["StationLoggingListFunction"]; + }; + "/station/logging/persist": { + /** @description Appends logging entries. */ + post: operations["StationLoggingPersistFunction"]; + }; + "/station/refresh-token": { + /** @description Refreshes the access token. */ + post: operations["StationRefreshTokenFunction"]; + }; + "/signalr/conducts/negotiate": { + /** @description Negotiates SignalR connection for conducts hub. */ + post: operations["ConductsNegotiateFunction"]; + }; + "/signalr/contacts/negotiate": { + /** @description Negotiates SignalR connection for entities hub. */ + post: operations["ContactsNegotiateFunction"]; + }; + "/share/entity": { + /** @description Shared the entity with users. */ + post: operations["ShareEntityFunction"]; + /** @description Un-shared the entity from users. */ + delete: operations["UnShareEntityFunction"]; + }; + "/entity": { + /** @description Retrieves all available entities. */ + get: operations["EntityRetrieveFunction"]; + /** @description Creates or updates entity. Will create entity if Id is not provided. */ + post: operations["EntityUpsertFunction"]; + /** @description Deletes the entity. */ + delete: operations["EntityDeleteFunction"]; + }; + "/entity/{id}": { + /** @description Retrieves entity. */ + get: operations["EntityRetrieveSingleFunction"]; + }; + "/entity/{id}/contacts/{channelName}/{contactName}": { + /** @description Sets contact value. */ + put: operations["EntityContactSet"]; + /** @description Deletes the contact. */ + delete: operations["ContactDeleteFunction"]; + }; + "/contact/history": { + /** @description Retrieves the contact history for provided duration. */ + get: operations["ContactHistoryRetrieveFunction"]; + }; + "/contact/metadata": { + /** @description Contact metadata. */ + post: operations["ContactMetadataFunction"]; + }; + "/contact/set": { + /** @description Sets contact value. */ + post: operations["ContactSetFunction"]; + }; + "/conducts/request": { + /** @description Requests conduct to be executed. */ + post: operations["ConductRequestFunction"]; + }; + "/conducts/request-multiple": { + /** @description Requests multiple conducts to be executed. */ + post: operations["ConductRequestMultipleFunction"]; + }; + "/auth/pats": { + /** @description Retrieve all user PATs. */ + get: operations["PatsRetrieveFunction"]; + /** @description Creates new PAT. */ + post: operations["PatsCreateFunction"]; + }; +} + +export type webhooks = Record; + +export interface components { + schemas: { + blobInfoDto: { + name?: string; + /** Format: date-time */ + createdTimeStamp?: string | null; + /** Format: date-time */ + modifiedTimeStamp?: string | null; + /** Format: int64 */ + size?: number | null; + }; + conductRequestDto: { + entityId: string; + channelName: string; + contactName: string; + valueSerialized?: string; + /** Format: double */ + delay?: number | null; + }; + contactDto: { + entityId?: string; + contactName?: string; + channelName?: string; + valueSerialized?: string; + /** Format: date-time */ + timeStamp?: string; + metadata?: string; + }; + contactHistoryResponseDto: { + values?: components["schemas"]["timeStampValuePair"][]; + }; + contactMetadataDto: { + entityId?: string; + channelName?: string; + contactName?: string; + metadata?: string; + }; + contactSetDto: { + entityId?: string; + channelName?: string; + contactName?: string; + valueSerialized?: string; + /** Format: date-time */ + timeStamp?: string | null; + }; + entityDeleteDto: { + id: string; + }; + entityDetailsDto: { + /** + * Format: int32 + * @default 0 + * @enum {integer} + */ + type?: 0 | 1 | 2 | 3 | 4 | 5 | 6; + id?: string; + alias?: string; + contacts?: components["schemas"]["contactDto"][]; + sharedWith?: components["schemas"]["userDto"][]; + }; + entityUpsertDto: { + id?: string; + /** + * Format: int32 + * @default 0 + * @enum {integer|null} + */ + type: 0 | 1 | 2 | 3 | 4 | 5 | 6 | null; + alias: string; + }; + entityUpsertResponseDto: { + id?: string; + }; + entry: { + /** Format: date-time */ + timeStamp?: string | null; + /** + * Format: int32 + * @default 0 + * @enum {integer|null} + */ + level?: 0 | 1 | 2 | 3 | 4 | 5 | null; + message?: string; + }; + newsletterSubscribeDto: { + email: string; + }; + patCreateDto: { + alias?: string; + /** Format: date-time */ + expire?: string | null; + }; + patCreateResponseDto: { + pat?: string; + }; + patDto: { + userId?: string; + patEnd?: string; + alias?: string; + /** Format: date-time */ + expire?: string | null; + }; + shareRequestDto: { + entityId: string; + userEmails: string[]; + }; + signalRConnectionInfo: { + url?: string; + accessToken?: string; + }; + stationRefreshTokenRequestDto: { + refreshToken: string; + }; + stationRefreshTokenResponseDto: { + accessToken?: string; + /** Format: date-time */ + expire?: string; + }; + stationsLoggingPersistRequestDto: { + stationId: string; + entries: components["schemas"]["entry"][]; + }; + timeStampValuePair: { + /** Format: date-time */ + timeStamp?: string; + valueSerialized?: string; + }; + unShareRequestDto: { + entityId: string; + userEmails: string[]; + }; + userDto: { + id?: string; + email?: string; + fullName?: string; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} + +export type $defs = Record; + +export type external = Record; + +export interface operations { + + HealthFunctions: { + responses: { + /** @description No description */ + 200: { + content: never; + }; + }; + }; + /** @description Subscribe to a newsletter. */ + NewsletterFunction: { + parameters: { + header?: { + /** @description hCaptcha response. */ + "HCAPTCHA-RESPONSE"?: string; + }; + }; + /** @description Subscribe with email address. */ + requestBody?: { + content: { + "application/json": components["schemas"]["newsletterSubscribeDto"]; + }; + }; + responses: { + /** @description No description */ + 200: { + content: never; + }; + /** @description One or more required property is missing in request. */ + 400: { + content: never; + }; + }; + }; + StationLoggingDownloadFunction: { + parameters: { + query: { + /** @description The **stationId** parameter */ + stationId: string; + /** @description The **blobName** parameter. Use list function to obtain available blobs. */ + blobName: string; + }; + }; + responses: { + /** @description One or more required property is missing in request. */ + 400: { + content: never; + }; + }; + }; + StationLoggingListFunction: { + parameters: { + query: { + /** @description The **StationID** parameter */ + stationId: string; + }; + }; + responses: { + /** @description List of blob infos. */ + 200: { + content: { + "application/json": components["schemas"]["blobInfoDto"][]; + }; + }; + /** @description One or more required property is missing in request. */ + 400: { + content: never; + }; + }; + }; + /** @description Appends logging entries. */ + StationLoggingPersistFunction: { + /** @description The logging entries to persist per station. */ + requestBody?: { + content: { + "application/json": components["schemas"]["stationsLoggingPersistRequestDto"]; + }; + }; + responses: { + /** @description No description */ + 200: { + content: never; + }; + /** @description One or more required property is missing in request. */ + 400: { + content: never; + }; + }; + }; + /** @description Refreshes the access token. */ + StationRefreshTokenFunction: { + requestBody?: { + content: { + "application/json": components["schemas"]["stationRefreshTokenRequestDto"]; + }; + }; + responses: { + /** @description Payload of StationRefreshTokenResponseDto */ + 200: { + content: { + "application/json": components["schemas"]["stationRefreshTokenResponseDto"]; + }; + }; + /** @description One or more required property is missing in request. */ + 400: { + content: never; + }; + }; + }; + /** @description Negotiates SignalR connection for conducts hub. */ + ConductsNegotiateFunction: { + responses: { + /** @description SignalR connection info. */ + 200: { + content: { + "application/json": components["schemas"]["signalRConnectionInfo"]; + }; + }; + }; + }; + /** @description Negotiates SignalR connection for entities hub. */ + ContactsNegotiateFunction: { + responses: { + /** @description SignalR connection info. */ + 200: { + content: { + "application/json": components["schemas"]["signalRConnectionInfo"]; + }; + }; + }; + }; + /** @description Shared the entity with users. */ + ShareEntityFunction: { + /** @description Share one entity with one or more users. */ + requestBody?: { + content: { + "application/json": components["schemas"]["shareRequestDto"]; + }; + }; + responses: { + /** @description No description */ + 200: { + content: never; + }; + /** @description One or more required property is missing in request. */ + 400: { + content: never; + }; + }; + }; + /** @description Un-shared the entity from users. */ + UnShareEntityFunction: { + /** @description Un-share one entity with one or more users. */ + requestBody?: { + content: { + "application/json": components["schemas"]["unShareRequestDto"]; + }; + }; + responses: { + /** @description No description */ + 200: { + content: never; + }; + /** @description One or more required property is missing in request. */ + 400: { + content: never; + }; + }; + }; + /** @description Retrieves all available entities. */ + EntityRetrieveFunction: { + responses: { + /** @description Payload of Array of EntityDetailsDto */ + 200: { + content: { + "application/json": components["schemas"]["entityDetailsDto"][]; + }; + }; + }; + }; + /** @description Creates or updates entity. Will create entity if Id is not provided. */ + EntityUpsertFunction: { + requestBody?: { + content: { + "application/json": components["schemas"]["entityUpsertDto"]; + }; + }; + responses: { + /** @description Payload of EntityUpsertResponseDto */ + 200: { + content: { + "application/json": components["schemas"]["entityUpsertResponseDto"]; + }; + }; + }; + }; + /** @description Deletes the entity. */ + EntityDeleteFunction: { + /** @description Information about entity to delete. */ + requestBody?: { + content: { + "application/json": components["schemas"]["entityDeleteDto"]; + }; + }; + responses: { + /** @description No description */ + 200: { + content: never; + }; + /** @description One or more required property is missing in request. */ + 400: { + content: never; + }; + /** @description No description */ + 404: { + content: never; + }; + }; + }; + /** @description Retrieves entity. */ + EntityRetrieveSingleFunction: { + parameters: { + path: { + /** @description Entity identifier */ + id: string; + }; + }; + responses: { + /** @description Payload of EntityDetailsDto */ + 200: { + content: { + "application/json": components["schemas"]["entityDetailsDto"]; + }; + }; + /** @description No description */ + 404: { + content: never; + }; + }; + }; + /** @description Sets contact value. */ + EntityContactSet: { + parameters: { + path: { + /** @description Entity identifier */ + id: string; + /** @description Channel name */ + channelName: string; + /** @description Contact name */ + contactName: string; + }; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["contactSetDto"]; + }; + }; + responses: { + /** @description No description */ + 200: { + content: never; + }; + /** @description One or more required property is missing in request. */ + 400: { + content: never; + }; + }; + }; + /** @description Deletes the contact. */ + ContactDeleteFunction: { + parameters: { + path: { + /** @description Entity identifier */ + id: string; + /** @description Channel name */ + channelName: string; + /** @description Contact name */ + contactName: string; + }; + }; + responses: { + /** @description No description */ + 200: { + content: never; + }; + }; + }; + /** @description Retrieves the contact history for provided duration. */ + ContactHistoryRetrieveFunction: { + responses: { + /** @description Payload of ContactHistoryResponseDto */ + 200: { + content: { + "application/json": components["schemas"]["contactHistoryResponseDto"]; + }; + }; + /** @description One or more required property is missing in request. */ + 400: { + content: never; + }; + }; + }; + /** @description Contact metadata. */ + ContactMetadataFunction: { + requestBody?: { + content: { + "application/json": components["schemas"]["contactMetadataDto"]; + }; + }; + responses: { + /** @description No description */ + 200: { + content: never; + }; + /** @description One or more required property is missing in request. */ + 400: { + content: never; + }; + }; + }; + /** @description Sets contact value. */ + ContactSetFunction: { + requestBody?: { + content: { + "application/json": components["schemas"]["contactSetDto"]; + }; + }; + responses: { + /** @description No description */ + 200: { + content: never; + }; + /** @description One or more required property is missing in request. */ + 400: { + content: never; + }; + }; + }; + /** @description Requests conduct to be executed. */ + ConductRequestFunction: { + /** @description The conduct to execute. */ + requestBody?: { + content: { + "application/json": components["schemas"]["conductRequestDto"]; + }; + }; + responses: { + /** @description No description */ + 200: { + content: never; + }; + /** @description One or more required property is missing in request. */ + 400: { + content: never; + }; + }; + }; + /** @description Requests multiple conducts to be executed. */ + ConductRequestMultipleFunction: { + /** @description Collection of conducts to execute. */ + requestBody?: { + content: { + "application/json": components["schemas"]["conductRequestDto"][]; + }; + }; + responses: { + /** @description No description */ + 200: { + content: never; + }; + /** @description One or more required property is missing in request. */ + 400: { + content: never; + }; + }; + }; + /** @description Retrieve all user PATs. */ + PatsRetrieveFunction: { + responses: { + /** @description Payload of Array of PatDto */ + 200: { + content: { + "application/json": components["schemas"]["patDto"][]; + }; + }; + }; + }; + /** @description Creates new PAT. */ + PatsCreateFunction: { + requestBody?: { + content: { + "application/json": components["schemas"]["patCreateDto"]; + }; + }; + responses: { + /** @description Payload of PatCreateResponseDto */ + 200: { + content: { + "application/json": components["schemas"]["patCreateResponseDto"]; + }; + }; + }; + }; +} diff --git a/web/apps/app/src/auth/pats/AuthPatsRepository.ts b/web/apps/app/src/auth/pats/AuthPatsRepository.ts new file mode 100644 index 0000000000..b552d73d1f --- /dev/null +++ b/web/apps/app/src/auth/pats/AuthPatsRepository.ts @@ -0,0 +1,25 @@ +import createClient from 'openapi-fetch'; +import { _getBearerTokenAsync, getApiUrl } from '../../services/HttpService'; +import { paths } from '../../api/signalco/schema'; // generated from openapi-typescript + +async function signalcoApiClient() { + return createClient({ + baseUrl: getApiUrl(), + headers: { Authorization: await _getBearerTokenAsync() }, + }); +} + +export async function authPatsAsync() { + const client = await signalcoApiClient(); + return (await client.GET('/auth/pats')).data; +} + +export async function authPatCreateAsync(alias: string | undefined, expire: Date | undefined) { + const client = await signalcoApiClient(); + return (await client.POST('/auth/pats', { + body: { + alias, + expire: expire instanceof Date ? expire.toISOString() : undefined + } + })).data; +} diff --git a/web/apps/app/src/hooks/signalco/pats/useAuthPats.ts b/web/apps/app/src/hooks/signalco/pats/useAuthPats.ts new file mode 100644 index 0000000000..78ab747db2 --- /dev/null +++ b/web/apps/app/src/hooks/signalco/pats/useAuthPats.ts @@ -0,0 +1,14 @@ +import { type UseQueryResult, useQuery } from '@tanstack/react-query'; +import { authPatsAsync } from '../../../auth/pats/AuthPatsRepository'; + +export function allAuthPatsKey() { + return ['auth-pats']; +} + +export function useAllAuthPats() { + return useQuery({ + queryKey: allAuthPatsKey(), + queryFn: async () => await authPatsAsync() ?? undefined, + staleTime: 60 * 1000 + }); +} diff --git a/web/apps/app/src/hooks/signalco/pats/useCreateAuthPat.ts b/web/apps/app/src/hooks/signalco/pats/useCreateAuthPat.ts new file mode 100644 index 0000000000..f932a81a85 --- /dev/null +++ b/web/apps/app/src/hooks/signalco/pats/useCreateAuthPat.ts @@ -0,0 +1,13 @@ +import { type UseMutationResult, useMutation, useQueryClient } from '@tanstack/react-query'; +import { authPatCreateAsync } from '../../../auth/pats/AuthPatsRepository'; +import { allAuthPatsKey } from './useAuthPats'; + +export function useCreateAuthPat() { + const client = useQueryClient(); + return useMutation({ + mutationFn: (data: { alias?: string, expire?: Date }) => authPatCreateAsync(data.alias, data.expire), + onSuccess: () => { + client.invalidateQueries({ queryKey: allAuthPatsKey() }); + } + }); +} diff --git a/web/apps/app/src/services/HttpService.ts b/web/apps/app/src/services/HttpService.ts index 836818dd12..81f0acecf2 100644 --- a/web/apps/app/src/services/HttpService.ts +++ b/web/apps/app/src/services/HttpService.ts @@ -1,8 +1,8 @@ import { isAbsoluteUrl, trimStartChar } from '@signalco/js'; import { signalcoApiEndpoint } from './AppSettingsProvider'; -export function getApiUrl(url: string): string { - return signalcoApiEndpoint() + trimStartChar(url, '/'); +export function getApiUrl(url?: string): string { + return signalcoApiEndpoint() + (url ? trimStartChar(url, '/') : ''); } let _tokenFactory: (() => Promise) | undefined = undefined; @@ -14,7 +14,7 @@ export function getTokenFactory() { return _tokenFactory; } -async function _getBearerTokenAsync() { +export async function _getBearerTokenAsync() { const tokenFactory = getTokenFactory(); if (tokenFactory) { const token = await tokenFactory(); diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index a54fc273c0..e73f2d51f7 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -128,6 +128,12 @@ importers: next-themes: specifier: 0.2.1 version: 0.2.1(next@14.0.4)(react-dom@18.2.0)(react@18.2.0) + openapi-fetch: + specifier: 0.8.2 + version: 0.8.2 + openapi-typescript: + specifier: 6.7.3 + version: 6.7.3 pigeon-maps: specifier: 0.21.3 version: 0.21.3(react@18.2.0) @@ -4374,6 +4380,11 @@ packages: resolution: {integrity: sha512-cEee/Z+I12mZcFJshKcCqC8tuX5hG3s+d+9nZ3LabqKF1vKdF41B92pJVCBggjAGORAeOzyyDDKrZwIkLffeOQ==} dev: true + /@fastify/busboy@2.1.0: + resolution: {integrity: sha512-+KpH+QxZU7O4675t3mnkQKcZZg56u+K/Ct2K+N2AZYNVK8kyeo/bI18tI8aPm3tvNNRyTWfj6s5tnGNlcbQRsA==} + engines: {node: '>=14'} + dev: false + /@floating-ui/core@1.5.0: resolution: {integrity: sha512-kK1h4m36DQ0UHGj5Ah4db7R0rHemTqqO0QLvUqi1/mUUp3LuAWbWxdxSIf/XsnH9VS6rRVPLJCncjRzUvyCLXg==} dependencies: @@ -9037,6 +9048,11 @@ packages: uri-js: 4.4.1 dev: true + /ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + dev: false + /ansi-html-community@0.0.8: resolution: {integrity: sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==} engines: {'0': node >= 0.8.0} @@ -15298,10 +15314,32 @@ packages: - encoding dev: false + /openapi-fetch@0.8.2: + resolution: {integrity: sha512-4g+NLK8FmQ51RW6zLcCBOVy/lwYmFJiiT+ckYZxJWxUxH4XFhsNcX2eeqVMfVOi+mDNFja6qDXIZAz2c5J/RVw==} + dependencies: + openapi-typescript-helpers: 0.0.5 + dev: false + /openapi-types@12.1.3: resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} dev: true + /openapi-typescript-helpers@0.0.5: + resolution: {integrity: sha512-MRffg93t0hgGZbYTxg60hkRIK2sRuEOHEtCUgMuLgbCC33TMQ68AmxskzUlauzZYD47+ENeGV/ElI7qnWqrAxA==} + dev: false + + /openapi-typescript@6.7.3: + resolution: {integrity: sha512-es3mGcDXV6TKPo6n3aohzHm0qxhLyR39MhF6mkD1FwFGjhxnqMqfSIgM0eCpInZvqatve4CxmXcMZw3jnnsaXw==} + hasBin: true + dependencies: + ansi-colors: 4.1.3 + fast-glob: 3.3.2 + js-yaml: 4.1.0 + supports-color: 9.4.0 + undici: 5.28.2 + yargs-parser: 21.1.1 + dev: false + /opener@1.5.2: resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} hasBin: true @@ -17979,6 +18017,11 @@ packages: dependencies: has-flag: 4.0.0 + /supports-color@9.4.0: + resolution: {integrity: sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw==} + engines: {node: '>=12'} + dev: false + /supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} @@ -18739,6 +18782,13 @@ packages: /undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + /undici@5.28.2: + resolution: {integrity: sha512-wh1pHJHnUeQV5Xa8/kyQhO7WFa8M34l026L5P/+2TYiakvGy5Rdc8jWZVyG7ieht/0WgJLEd3kcU5gKx+6GC8w==} + engines: {node: '>=14.0'} + dependencies: + '@fastify/busboy': 2.1.0 + dev: false + /unicode-canonical-property-names-ecmascript@2.0.0: resolution: {integrity: sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==} engines: {node: '>=4'} @@ -19919,6 +19969,11 @@ packages: resolution: {integrity: sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==} engines: {node: '>= 14'} + /yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + dev: false + /yauzl@2.10.0: resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} dependencies: