feat: install key and account config webgui requests

This commit is contained in:
Zack Spear
2023-06-07 17:30:31 -07:00
committed by Zack Spear
parent 9f12d62c80
commit 55df4a9738
20 changed files with 6326 additions and 2595 deletions

View File

@@ -51,7 +51,7 @@ const ariaLablledById = computed((): string|undefined => props.title ? `ModalTit
title="Click to close modal"
/>
</TransitionChild>
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<div class="text-center flex min-h-full items-center justify-center p-4 md:p-0">
<TransitionChild
appear
as="template"

View File

@@ -3,6 +3,7 @@ import { storeToRefs } from 'pinia';
import 'tailwindcss/tailwind.css';
import '~/assets/main.css';
import { useCallbackStore } from '~/store/callback';
import { useInstallKeyStore } from '~/store/installKey';
export interface Props {
open?: boolean;
@@ -13,28 +14,33 @@ withDefaults(defineProps<Props>(), {
});
const callbackStore = useCallbackStore();
const { decryptedData } = storeToRefs(callbackStore);
const { callbackLoading, decryptedData } = storeToRefs(callbackStore);
const close = () => {
if (callbackLoading.value) return console.debug('[close] not allowed');
callbackStore.hide();
};
</script>
<template>
<Modal
:open="open"
@close="callbackStore.hide()"
@close="close"
max-width="max-w-800px"
:show-close-x="!callbackLoading"
>
<div class="text-center relative w-full flex flex-col gap-y-16px">
<header>
<h1 class="text-24px font-semibold flex flex-wrap justify-center gap-x-1">Callback Feedback</h1>
</header>
<BrandLoading class="w-90px mx-auto" />
<BrandLoading v-if="callbackLoading" class="w-90px mx-auto" />
<pre class="text-left text-black p-8px w-full overflow-scroll bg-gray-400">{{ JSON.stringify(decryptedData, null, 2) }}</pre>
<div class="w-full max-w-xs flex flex-col gap-y-16px mx-auto">
<div v-if="!callbackLoading" class="w-full max-w-xs flex flex-col gap-y-16px mx-auto">
<button
@click="callbackStore.hide()"
@click="close"
class="text-12px tracking-wide inline-block mx-8px opacity-60 hover:opacity-100 focus:opacity-100 underline transition"
:title="'Close Promo'"
>
{{ 'Close' }}
</button>

View File

@@ -68,14 +68,14 @@ const installButtonClasses = 'text-white text-14px text-center w-full flex flex-
:show-close-x="true"
max-width="max-w-800px"
>
<div class="text-center relative w-full p-24px">
<div class="text-center relative w-full md:p-24px">
<header>
<h1 class="text-24px font-semibold flex flex-wrap justify-center gap-x-1">
Introducing Unraid Connect
<span><UpcBeta class="relative -top-1" /></span>
</h1>
<h2 class="text-20px">
Enhance your Unraid experience with these features
Enhance your Unraid experience
</h2>
</header>

View File

@@ -0,0 +1,25 @@
import wretch from 'wretch';
import FormUrlAddon from 'wretch/addons/formUrl';
import QueryStringAddon from 'wretch/addons/queryString';
import { useErrorsStore } from '~/store/errors';
const errorsStore = useErrorsStore();
export const request = wretch()
.addon(FormUrlAddon)
.addon(QueryStringAddon)
.errorType('json')
.resolve((response) => {
return (
response
.error("Error", (error) => {
console.log('global catch (Error class)', error);
errorsStore.setError(error);
})
.error("TypeError", (error) => {
console.log('global type error catch (TypeError class)', error);
errorsStore.setError(error);
})
);
});

View File

@@ -0,0 +1,36 @@
import { request } from '~/composables/services/request';
/**
* @name WebguiInstallKey
* @description used to auto install key urls
* @type GET - data should be passed using wretch's `.query({ url: String })`
* @param {string} url - URL of license key
*/
export const WebguiInstallKey = request.url('/webGui/include/InstallKey.php');
/**
* @type POST
* @dataType - `formUrl(Object)` https://github.com/elbywan/wretch#formurlinput-object--string
* @param {string} csrf_token
* @param {string} '#file' - ex: getters.myServersCfgPath
* @param {string} '#section' - ex: 'remote'
* @param {string} apikey
* @param {string} avatar
* @param {string} email
* @param {string} username
*/
export const WebguiUpdate = request.url('/update.php');
/**
* @name WebguiUpdateDns
* @dataForm formUrl
* @description Used after Sign In to ensure URLs will work correctly
* @note this request is delayed by 500ms to allow server to process key install fully
* @todo potentially remove delay
* @param csrf_token
* @type POST
*/
export const WebguiUpdateDns = request.url('/webGui/include/UpdateDNS.php');
/**
* @name WebguiUpdateDns
* @description used after Sign In to ensure URLs will work correctly
* @type POST
*/
export const WebguiUnraidApiCommand = request.url('/plugins/dynamix.my.servers/include/unraid-api.php');

8562
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,7 @@
"@types/node": "^18",
"@vueuse/core": "^10.1.2",
"@vueuse/nuxt": "^10.1.2",
"amazon-cognito-identity-js": "^6.2.0",
"nuxt": "^3.5.1",
"nuxt-custom-elements": "^2.0.0-beta.12"
},
@@ -29,7 +30,8 @@
"crypto-js": "^4.1.1",
"dayjs": "^1.11.7",
"focus-trap": "^7.4.3",
"hex-to-rgba": "^2.0.1"
"hex-to-rgba": "^2.0.1",
"wretch": "^2.5.2"
},
"overrides": {
"vue": "latest"

View File

@@ -1,8 +1,8 @@
import { useToggle } from '@vueuse/core';
import { defineStore, createPinia, setActivePinia } from "pinia";
import { defineStore, createPinia, setActivePinia } from 'pinia';
import { useCallbackStore } from './callback';
import { useServerStore } from './server';
import { WebguiUpdate } from '~/composables/services/webgui';
import type { CallbackAction } from '~/types/callback';
/**
* @see https://stackoverflow.com/questions/73476371/using-pinia-with-vue-js-web-components
* @see https://github.com/vuejs/pinia/discussions/1085
@@ -13,7 +13,8 @@ export const useAccountStore = defineStore('account', () => {
const callbackStore = useCallbackStore();
const serverStore = useServerStore();
// State
const accountVisible = ref<boolean>(false);
const updating = ref(false);
const updateSuccess = ref<boolean|undefined>(undefined);
// Actions
const recover = () => {
console.debug('[recover]');
@@ -43,24 +44,63 @@ export const useAccountStore = defineStore('account', () => {
type: 'signOut',
});
};
const accountHide = () => accountVisible.value = false;
const accountShow = () => accountVisible.value = true;
const accountToggle = useToggle(accountVisible);
watch(accountVisible, (newVal, _oldVal) => {
console.debug('[accountVisible]', newVal, _oldVal);
});
/**
* @description Update myservers.cfg for both Sign In & Sign Out
* @note unraid-api requires apikey & token realted keys to be lowercase
*/
const updatePluginConfig = async (action: CallbackAction) => {
console.debug('[updatePluginConfig]', action);
updating.value = true;
const userPayload = {
...(action.user
? {
accesstoken: action.user.signInUserSession.accessToken.jwtToken,
apikey: serverStore.apiKey,
// avatar: action.user?.attributes.avatar,
email: action.user?.attributes.email,
idtoken: action.user.signInUserSession.idToken.jwtToken,
refreshtoken: action.user.signInUserSession.refreshToken.token,
regWizTime: `${Date.now()}_${serverStore.guid}`, // set when signing in the first time and never unset for the sake of displaying Sign In/Up in the UPC without needing to validate guid every time
username: action.user?.attributes.preferred_username,
}
: {
accesstoken: '',
apikey: '',
avatar: '',
email: '',
idtoken: '',
refreshtoken: '',
username: '',
}),
};
try {
const response = await WebguiUpdate
.formUrl({
csrf_token: serverStore.csrf,
'#file': 'dynamix.my.servers/myservers.cfg',
'#section': 'remote',
...userPayload,
})
.post();
console.debug('[updatePluginConfig] WebguiUpdate response', response);
updateSuccess.value = true;
} catch (error) {
console.debug('[updatePluginConfig] WebguiUpdate error', error);
updateSuccess.value = false;
} finally {
updating.value = false;
}
};
return {
// State
accountVisible,
accountHide,
accountShow,
updating,
updateSuccess,
// Actions
recover,
replace,
signIn,
signOut,
accountToggle,
updatePluginConfig,
};
});

View File

@@ -1,7 +1,9 @@
import AES from 'crypto-js/aes';
import Utf8 from 'crypto-js/enc-utf8';
import { defineStore, createPinia, setActivePinia } from "pinia";
import type { CallbackSendPayload } from '~/types/callback';
import { defineStore, createPinia, setActivePinia } from 'pinia';
import { useAccountStore } from './account';
import { useInstallKeyStore } from './installKey';
import type { CallbackSendPayload, CallbackReceivePayload } from '~/types/callback';
import type {
ServerAccountCallbackSendPayload,
ServerPurchaseCallbackSendPayload,
@@ -14,14 +16,16 @@ setActivePinia(createPinia());
export const useCallbackStore = defineStore('callback', () => {
// store helpers
const accountStore = useAccountStore();
const installKeyStore = useInstallKeyStore();
// const config = useRuntimeConfig(); // results in a nuxt error after web components are built
// const encryptKey = config.public.callbackKey;
const encryptKey = 'Uyv2o8e*FiQe8VeLekTqyX6Z*8XonB';
// state
const currentUrl = ref();
const callbackFeedbackVisible = ref<boolean>(false);
const decryptedData = ref();
const encryptedMessage = ref('');
const callbackLoading = ref(false);
const decryptedData = ref<CallbackReceivePayload|null>(null);
const encryptedMessage = ref<string|null>(null);
// actions
const send = (url: string = 'https://unraid.ddev.site/init-purchase', payload: CallbackSendPayload) => {
console.debug('[send]');
@@ -45,25 +49,39 @@ export const useCallbackStore = defineStore('callback', () => {
if (!callbackValue) {
return console.debug('[watcher] no callback to handle');
}
callbackLoading.value = true;
const decryptedMessage = AES.decrypt(callbackValue, encryptKey);
decryptedData.value = JSON.parse(decryptedMessage.toString(Utf8));
console.debug('[watcher]', decryptedMessage, decryptedData.value);
if (!decryptedData.value) return console.error('Callback Watcher: Data not present');
if (!decryptedData.value.action) return console.error('Callback Watcher: Required "action" not present');
if (!decryptedData.value) {
callbackLoading.value = false;
return console.error('Callback Watcher: Data not present');
}
if (!decryptedData.value.actions) {
callbackLoading.value = false;
return console.error('Callback Watcher: Required "action" not present');
}
// Display the feedback modal
show();
// Parse the data and perform actions
switch (decryptedData.value.action) {
case 'install':
console.debug(`Installing key ${decryptedData.value.keyUrl}\n\nOEM: ${decryptedData.value.oem}\n\nSender: ${decryptedData.value.sender}`);
break;
case 'register':
console.debug('[Register action]');
break;
default:
console.error('Callback Watcher: Invalid "action"');
break;
}
decryptedData.value.actions.forEach(async (action, index, array) => {
console.debug('[action]', action);
if (action.keyUrl) {
const response = await installKeyStore.install(action);
console.debug('[action] installKeyStore.install response', response);
}
if (action.user) {
const response = await accountStore.updatePluginConfig(action);
console.debug('[action] accountStore.updatePluginConfig', response);
}
// all actions have run
if (array.length === (index + 1)) {
console.debug('[actions] DONE');
setTimeout(() => {
callbackLoading.value = false;
}, 5000);
}
});
};
const hide = () => {
@@ -87,8 +105,9 @@ export const useCallbackStore = defineStore('callback', () => {
return {
// state
decryptedData,
callbackFeedbackVisible,
callbackLoading,
decryptedData,
// actions
send,
watcher,

View File

@@ -1,5 +1,5 @@
import { useToggle } from '@vueuse/core';
import { defineStore, createPinia, setActivePinia } from "pinia";
import { defineStore, createPinia, setActivePinia } from 'pinia';
/**
* @see https://stackoverflow.com/questions/73476371/using-pinia-with-vue-js-web-components
* @see https://github.com/vuejs/pinia/discussions/1085

30
store/errors.ts Normal file
View File

@@ -0,0 +1,30 @@
import { defineStore, createPinia, setActivePinia } from 'pinia';
// import { useAccountStore } from './account';
// import { useCallbackStore } from './callback';
// import { useInstallKeyStore } from './installKey';
// import { useServerStore } from './server';
/**
* @see https://stackoverflow.com/questions/73476371/using-pinia-with-vue-js-web-components
* @see https://github.com/vuejs/pinia/discussions/1085
*/
setActivePinia(createPinia());
export const useErrorsStore = defineStore('errors', () => {
// const accountStore = useAccountStore();
// const callbackStore = useCallbackStore();
// const installKeyStore = useInstallKeyStore();
// const serverStore = useServerStore();
/** @todo type the errors */
const errors = ref<any[]>([]);
const setError = (error: any) => {
errors.value.push(error);
};
return {
errors,
setError,
};
});

53
store/installKey.ts Normal file
View File

@@ -0,0 +1,53 @@
import { defineStore, createPinia, setActivePinia } from 'pinia';
import { delay } from 'wretch/middlewares';
import { WebguiInstallKey, WebguiUpdateDns } from '~/composables/services/webgui';
import { useServerStore } from './server';
import type { CallbackAction } from '~/types/callback';
/**
* @see https://stackoverflow.com/questions/73476371/using-pinia-with-vue-js-web-components
* @see https://github.com/vuejs/pinia/discussions/1085
*/
setActivePinia(createPinia());
export const useInstallKeyStore = defineStore('installKey', () => {
const serverStore = useServerStore();
const installing = ref(false);
const success = ref<boolean|undefined>();
const install = async (action: CallbackAction) => {
console.debug('[install]');
installing.value = true;
try {
const response = await WebguiInstallKey
.query({ url: action.keyUrl })
.get();
console.log('[install] WebguiInstallKey response', response);
success.value = true;
try {
const response = await WebguiUpdateDns
.middlewares([
delay(500)
])
.formUrl({ csrf_token: serverStore.csrf })
.post();
console.log('[install] WebguiUpdateDns response', response);
} catch (error) {
console.log('[install] WebguiUpdateDns error', error);
}
} catch (error) {
console.log('[install] WebguiInstallKey error', error);
success.value = false;
} finally {
installing.value = false;
}
};
return {
// State
installing,
success,
// Actions
install,
};
});

View File

@@ -1,5 +1,5 @@
import { useToggle } from '@vueuse/core';
import { defineStore, createPinia, setActivePinia } from "pinia";
import { defineStore, createPinia, setActivePinia } from 'pinia';
/**
* @see https://stackoverflow.com/questions/73476371/using-pinia-with-vue-js-web-components

View File

@@ -1,5 +1,5 @@
import { useToggle } from '@vueuse/core';
import { defineStore, createPinia, setActivePinia } from "pinia";
import { defineStore, createPinia, setActivePinia } from 'pinia';
/**
* @see https://stackoverflow.com/questions/73476371/using-pinia-with-vue-js-web-components

View File

@@ -1,5 +1,5 @@
import { useToggle } from '@vueuse/core';
import { defineStore, createPinia, setActivePinia } from "pinia";
import { defineStore, createPinia, setActivePinia } from 'pinia';
import { useCallbackStore } from './callback';
import { useServerStore } from './server';

View File

@@ -1,4 +1,4 @@
import { defineStore, createPinia, setActivePinia } from "pinia";
import { defineStore, createPinia, setActivePinia } from 'pinia';
import { ArrowRightOnRectangleIcon, GlobeAltIcon, KeyIcon } from '@heroicons/vue/24/solid';
import { useAccountStore } from './account';
@@ -30,6 +30,7 @@ export const useServerStore = defineStore('server', () => {
*/
const avatar = ref<string>(''); // @todo potentially move to a user store
const apiKey = ref<string>(''); // @todo potentially move to a user store
const csrf = ref<string>(''); // required to make requests to Unraid webgui
const description = ref<string>('');
const deviceCount = ref<number>(0);
const expireTime = ref<number>(0);
@@ -397,6 +398,7 @@ export const useServerStore = defineStore('server', () => {
console.debug('[setServer] data', data);
if (typeof data?.apiKey !== 'undefined') apiKey.value = data.apiKey;
if (typeof data?.avatar !== 'undefined') avatar.value = data.avatar;
if (typeof data?.csrf !== 'undefined') csrf.value = data.csrf;
if (typeof data?.description !== 'undefined') description.value = data.description;
if (typeof data?.deviceCount !== 'undefined') deviceCount.value = data.deviceCount;
if (typeof data?.expireTime !== 'undefined') expireTime.value = data.expireTime;
@@ -429,6 +431,7 @@ export const useServerStore = defineStore('server', () => {
// state
apiKey,
avatar,
csrf,
description,
deviceCount,
expireTime,

View File

@@ -1,4 +1,4 @@
import { defineStore, createPinia, setActivePinia } from "pinia";
import { defineStore, createPinia, setActivePinia } from 'pinia';
import hexToRgba from 'hex-to-rgba';
import type { Theme } from "~/types/theme";
/**

View File

@@ -1,5 +1,4 @@
import { useToggle } from '@vueuse/core';
import { defineStore, createPinia, setActivePinia } from "pinia";
import { defineStore, createPinia, setActivePinia } from 'pinia';
import { useCallbackStore } from './callback';
import { useServerStore } from './server';

View File

@@ -1,9 +1,50 @@
import type { CognitoUser, ChallengeName } from 'amazon-cognito-identity-js';
import type {
ServerAccountCallbackSendPayload,
ServerPurchaseCallbackSendPayload,
ServerStateDataActionType,
} from '~/types/server';
/**
* These user interfaces are mimiced from the Auth repo
*/
export interface UserInfo {
'custom:ips_id'?: string;
email?: string;
email_verifed?: 'true' | 'false';
preferred_username?: string;
sub?: string;
username?: string;
}
export interface AuthUser extends CognitoUser {
attributes: UserInfo;
username?: string;
preferredMFA: ChallengeName;
signInUserSession: {
accessToken: {
jwtToken: string;
};
idToken: {
jwtToken: string;
};
refreshToken: {
token: string;
};
};
}
export interface CallbackSendPayload extends ServerAccountCallbackSendPayload, ServerPurchaseCallbackSendPayload {
type: ServerStateDataActionType;
}
export interface CallbackAction {
keyUrl?: string;
type: ServerStateDataActionType;
user?: AuthUser;
}
export interface CallbackReceivePayload {
actions: CallbackAction[];
sender: string;
}

View File

@@ -23,6 +23,7 @@ export enum ServerState {
export interface Server {
apiKey?: string;
avatar?: string;
csrf?: string;
description?: string;
deviceCount?: number;
expireTime?: number;