Sheet use limited input device auth flow (#782)

* refactor: limited-input-device auth

* refactor: resolve sheet directory from setup

* refactor: extract sheet logic in backend

* refactor: simplify sheet integration

---------

Co-authored-by: cv <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>
This commit is contained in:
Alex Christoffer Rasmussen
2024-02-24 11:58:50 +01:00
committed by GitHub
parent fc5338903b
commit 474f1e2177
54 changed files with 1636 additions and 1144 deletions
@@ -10,6 +10,7 @@ export const RUNDOWN = ['rundown'];
export const RUNTIME = ['runtimeStore'];
export const SHEET_STATE = ['sheetState'];
export const USERFIELDS = ['userFields'];
export const CUSTOM_FIELDS = ['customFields'];
export const VIEW_SETTINGS = ['viewSettings'];
const location = window.location;
+63 -48
View File
@@ -1,6 +1,10 @@
import axios, { AxiosResponse } from 'axios';
import {
Alias,
AuthenticationStatus,
CustomField,
CustomFieldLabel,
CustomFields,
DatabaseModel,
GetInfo,
HttpSettings,
@@ -18,7 +22,7 @@ import { ExcelImportMap } from 'ontime-utils';
import { apiRepoLatest } from '../../externals';
import fileDownload from '../utils/fileDownload';
import { ontimeURL } from './apiConstants';
import { ontimeURL, projectDataURL } from './apiConstants';
/**
* @description HTTP request to retrieve application settings
@@ -257,79 +261,65 @@ export async function loadProject(filename: string): Promise<MessageResponse> {
}
/**
* @description STEP 1
* @description HTTP request to initiate the authentication service with google
*/
export const uploadSheetClientFile = async (file: File) => {
export const requestConnection = async (
file: File,
sheetId: string,
): Promise<{
verification_url: string;
user_code: string;
}> => {
const formData = new FormData();
formData.append('userFile', file);
const res = await axios
.post(`${ontimeURL}/sheet/clientsecret`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
.then((response) => response.data.id);
return res;
};
formData.append('client_secret', file);
const response = await axios.post(`${ontimeURL}/sheet/${sheetId}/connect`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
/**
* @description STEP 1 test
*/
// TODO: do we still need this?
export const getClientSecret = async () => {
const response = await axios.get(`${ontimeURL}/sheet/clientsecret`);
return response.data;
};
/**
* @description STEP 2
* @description HTTP request to verify whether we are authenticated with Google Sheet service
*/
export const getSheetsAuthUrl = async () => {
const response = await axios.get(`${ontimeURL}/sheet/authentication/url`);
export const verifyAuthenticationStatus = async (): Promise<{ authenticated: AuthenticationStatus }> => {
const response = await axios.get(`${ontimeURL}/sheet/connect`);
return response.data;
};
/**
* @description STEP 2 test
* @description HTTP request to revoke authentication to google sheet
*/
export const getAuthentication = async () => {
const response = await axios.get(`${ontimeURL}/sheet/authentication`);
export const revokeAuthentication = async (): Promise<{ authenticated: AuthenticationStatus }> => {
const response = await axios.post(`${ontimeURL}/sheet/revoke`);
return response.data;
};
/**
* @description STEP 3
* @returns worksheetOptions
* @description HTTP request to upload preview the contents of a google sheet as rundown
*/
export const postId = async (sheetId: string) => {
const response = await axios.post(`${ontimeURL}/sheet/sheetId`, { sheetId });
export const previewRundown = async (
sheetId: string,
options: ExcelImportMap,
): Promise<{
rundown: OntimeRundown;
userFields: UserFields;
}> => {
const response = await axios.post(`${ontimeURL}/sheet/${sheetId}/read`, { options });
return response.data;
};
/**
* @description STEP 4
* @description HTTP request to upload the rundown to a google sheet
*/
export const postWorksheet = async (sheetId: string, worksheet: string) => {
const response = await axios.post(`${ontimeURL}/sheet/worksheet`, { sheetId, worksheet });
export const uploadRundown = async (sheetId: string, options: ExcelImportMap): Promise<void> => {
const response = await axios.post(`${ontimeURL}/sheet/${sheetId}/write`, { options });
return response.data;
};
/**
* @description STEP 5
*/
export const postPreviewSheet = async (sheetId: string, options: ExcelImportMap) => {
const response = await axios.post(`${ontimeURL}/sheet-pull`, { sheetId, options });
return response.data.data;
};
/**
* @description STEP 5
*/
export const postPushSheet = async (sheetId: string, options: ExcelImportMap) => {
const response = await axios.post(`${ontimeURL}/sheet-push`, { sheetId, options });
return response.data.data;
};
/**
* @description HTTP request to rename a project file
*/
@@ -374,8 +364,33 @@ export async function createProject(
}
>,
): Promise<MessageResponse> {
// TODO: is this URL correct?
const url = `${ontimeURL}/project`;
const decodedUrl = decodeURIComponent(url);
const res = await axios.post(decodedUrl, project);
return res.data;
}
export async function getCustomFields(): Promise<CustomFields> {
const res = await axios.get(`${projectDataURL}/custom-field`);
return res.data;
}
export async function postCustomField(newField: CustomField): Promise<CustomFields> {
const res = await axios.post(`${projectDataURL}/custom-field`, {
...newField,
});
return res.data;
}
export async function editCustomField(label: CustomFieldLabel, newField: CustomField): Promise<CustomFields> {
const res = await axios.put(`${projectDataURL}/custom-field/${label}`, {
...newField,
});
return res.data;
}
export async function deleteCustomField(label: CustomFieldLabel): Promise<CustomFields> {
const res = await axios.delete(`${projectDataURL}/custom-field/${label}`);
return res.data;
}
@@ -10,20 +10,28 @@ interface CopyTagProps {
label: string;
className?: string;
size?: Size;
disabled?: boolean;
}
export default function CopyTag(props: PropsWithChildren<CopyTagProps>) {
const { label, className, size = 'xs', children } = props;
const { label, className, size = 'xs', disabled, children } = props;
const handleClick = () => copyToClipboard(children as string);
return (
<Tooltip label={label} openDelay={tooltipDelayFast}>
<ButtonGroup size={size} isAttached className={className}>
<Button variant='ontime-subtle' tabIndex={-1}>
<Button variant='ontime-subtle' tabIndex={-1} isDisabled={disabled}>
{children}
</Button>
<IconButton aria-label={label} icon={<IoCopy />} variant='ontime-filled' tabIndex={-1} onClick={handleClick} />
<IconButton
aria-label={label}
icon={<IoCopy />}
variant='ontime-filled'
tabIndex={-1}
onClick={handleClick}
isDisabled={disabled}
/>
</ButtonGroup>
</Tooltip>
);
@@ -6,21 +6,24 @@ import style from './SwatchSelect.module.scss';
interface SwatchProps {
color: string;
onClick: (color: string) => void;
onClick?: (color: string) => void;
isSelected?: boolean;
}
export default function Swatch(props: SwatchProps) {
const { color, isSelected, onClick } = props;
const classes = cx([style.swatch, isSelected ? style.selected : null]);
const handleClick = () => {
onClick?.(color);
};
const classes = cx([style.swatch, isSelected ? style.selected : null, onClick ? style.selectable : null]);
if (!color) {
return (
<div className={`${classes} ${style.center}`} onClick={() => onClick('')}>
<div className={`${classes} ${style.center}`} onClick={handleClick}>
<IoBan />
</div>
);
}
return <div className={classes} style={{ backgroundColor: `${color}` }} onClick={() => onClick(color)} />;
return <div className={classes} style={{ backgroundColor: `${color}` }} onClick={handleClick} />;
}
@@ -5,7 +5,6 @@
}
.swatch {
cursor: pointer;
width: 2rem;
height: 2rem;
aspect-ratio: 1;
@@ -15,6 +14,10 @@
&.selected {
border: 2px solid $blue-500;
}
&.selectable {
cursor: pointer;
}
}
.center {
@@ -0,0 +1,22 @@
import { useQuery } from '@tanstack/react-query';
import { CustomFields } from 'ontime-types';
import { queryRefetchInterval } from '../../ontimeConfig';
import { CUSTOM_FIELDS } from '../api/apiConstants';
import { getCustomFields } from '../api/ontimeApi';
const placeholder: CustomFields = {};
export default function useCustomFields() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: CUSTOM_FIELDS,
queryFn: getCustomFields,
placeholderData: placeholder,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchInterval,
networkMode: 'always',
});
return { data: data ?? placeholder, status, isFetching, isError, refetch };
}
@@ -1,4 +1,4 @@
import { isIPAddress, isOnlyNumbers, startsWithHttp, startsWithSlash } from '../regex';
import { isAlphanumeric, isIPAddress, isNotEmpty, isOnlyNumbers, startsWithHttp, startsWithSlash } from '../regex';
describe('simple tests for regex', () => {
test('isOnlyNumbers', () => {
@@ -48,4 +48,28 @@ describe('simple tests for regex', () => {
expect(startsWithSlash.test(t)).toBe(false);
});
});
test('isAlphanumeric', () => {
const right = ['dsafdsafa9f9sdafdsSADFHASDF', '1231', '1', 'a', 'asdas1asdas', '11as', '1'];
const wrong = ['with space', 'with @', '#'];
right.forEach((t) => {
expect(isAlphanumeric.test(t)).toBe(true);
});
wrong.forEach((t) => {
expect(isAlphanumeric.test(t)).toBe(false);
});
});
test('isNotEmpty', () => {
const right = ['notempty'];
const wrong = ['', ' '];
right.forEach((t) => {
expect(isNotEmpty.test(t)).toBe(true);
});
wrong.forEach((t) => {
expect(isNotEmpty.test(t)).toBe(false);
});
});
});
+13 -1
View File
@@ -8,7 +8,19 @@ import { OntimeEvent, SupportedEvent } from 'ontime-types';
*/
type ClonedEvent = Omit<
OntimeEvent,
'id' | 'cue' | 'user0' | 'user1' | 'user2' | 'user3' | 'user4' | 'user5' | 'user6' | 'user7' | 'user8' | 'user9'
| 'id'
| 'cue'
| 'user0'
| 'user1'
| 'user2'
| 'user3'
| 'user4'
| 'user5'
| 'user6'
| 'user7'
| 'user8'
| 'user9'
| 'custom'
>;
export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => {
return {
+2
View File
@@ -7,3 +7,5 @@ export const isOnlyNumbers = /^\d+$/;
export const isIPAddress = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/;
export const startsWithHttp = /^http:\/\//;
export const startsWithSlash = /^\//;
export const isAlphanumeric = /^[a-z0-9]+$/i;
export const isNotEmpty = /\S/;