diff options
author | vnugent <public@vaughnnugent.com> | 2024-01-30 15:23:06 -0500 |
---|---|---|
committer | vnugent <public@vaughnnugent.com> | 2024-01-30 15:23:06 -0500 |
commit | 5abc489b9954111d66d1385aa62a3ea962fa0a55 (patch) | |
tree | b1715e5c5e6316f33e3e33fb55397d93200ab518 /front-end/src/store | |
parent | e4dc63ded324c6e9678603296953bb1f7dea7569 (diff) |
merge upstream. Add dynamic client-side support for optional oauth2 and social login methods
Diffstat (limited to 'front-end/src/store')
-rw-r--r-- | front-end/src/store/index.ts | 4 | ||||
-rw-r--r-- | front-end/src/store/mfaSettingsPlugin.ts | 109 | ||||
-rw-r--r-- | front-end/src/store/oauthAppsPlugin.ts | 154 | ||||
-rw-r--r-- | front-end/src/store/pageProtectionPlugin.ts | 10 | ||||
-rw-r--r-- | front-end/src/store/socialMfaPlugin.ts | 31 | ||||
-rw-r--r-- | front-end/src/store/userProfile.ts | 30 |
6 files changed, 272 insertions, 66 deletions
diff --git a/front-end/src/store/index.ts b/front-end/src/store/index.ts index 1b2d7ee..936dddf 100644 --- a/front-end/src/store/index.ts +++ b/front-end/src/store/index.ts @@ -16,10 +16,12 @@ import { useSession } from "@vnuge/vnlib.browser"; import { set } from "@vueuse/core"; import { defineStore } from "pinia"; -import { computed, shallowRef } from "vue"; +import { computed, shallowRef, type UnwrapNestedRefs } from "vue"; export { SortType, QueryType } from './sharedTypes' +export const storeExport = <T>(val: T): UnwrapNestedRefs<T> => val as UnwrapNestedRefs<T>; + /** * Loads the main store for the application */ diff --git a/front-end/src/store/mfaSettingsPlugin.ts b/front-end/src/store/mfaSettingsPlugin.ts index dffafce..b801f32 100644 --- a/front-end/src/store/mfaSettingsPlugin.ts +++ b/front-end/src/store/mfaSettingsPlugin.ts @@ -1,62 +1,99 @@ import 'pinia' -import { MaybeRef, shallowRef, watch } from 'vue'; -import { MfaMethod, PkiPublicKey, apiCall, useMfaConfig, usePkiConfig, usePkiAuth } from '@vnuge/vnlib.browser'; -import { useToggle, get } from '@vueuse/core'; +import { MaybeRef, ref, shallowRef, watch } from 'vue'; +import { MfaMethod, PkiPublicKey, apiCall, useMfaConfig, usePkiConfig, usePkiAuth, MfaApi } from '@vnuge/vnlib.browser'; +import { useToggle, get, set } from '@vueuse/core'; import { PiniaPluginContext, PiniaPlugin, storeToRefs } from 'pinia' import { includes } from 'lodash-es'; +import { storeExport, } from './index'; + +interface PkiStore { + publicKeys: PkiPublicKey[] + pkiConfig: ReturnType<typeof usePkiConfig> + pkiAuth: ReturnType<typeof usePkiAuth> + refresh: () => void +} + +export interface MfaSettingsStore{ + mfa:{ + enabledMethods: MfaMethod[] + refresh: () => void + } & MfaApi + pki?: PkiStore +} declare module 'pinia' { - export interface PiniaCustomProperties { - mfaEndabledMethods: MfaMethod[] - mfaConfig: ReturnType<typeof useMfaConfig> - pkiConfig: ReturnType<typeof usePkiConfig> - pkiAuth: ReturnType<typeof usePkiAuth> - pkiPublicKeys: PkiPublicKey[] - mfaRefreshMethods: () => void + export interface PiniaCustomProperties extends MfaSettingsStore { + } } export const mfaSettingsPlugin = (mfaEndpoint: MaybeRef<string>, pkiEndpoint?:MaybeRef<string>): PiniaPlugin => { - return ({ store }: PiniaPluginContext) => { + return ({ store }: PiniaPluginContext): MfaSettingsStore => { const { loggedIn } = storeToRefs(store) const mfaConfig = useMfaConfig(mfaEndpoint) - const pkiConfig = usePkiConfig(pkiEndpoint || '/') - const pkiAuth = usePkiAuth(pkiEndpoint || '/') - const [onRefresh, mfaRefreshMethods] = useToggle() + + const [onRefresh, refresh] = useToggle() + + const enabledMethods = ref<MfaMethod[]>([]) + + const usePki = () => { + + const publicKeys = shallowRef<PkiPublicKey[]>([]) + + const pkiConfig = usePkiConfig(pkiEndpoint || '/') + const pkiAuth = usePkiAuth(pkiEndpoint || '/') + + //Watch for changes to mfa methods (refresh) and update the pki keys + watch([enabledMethods], ([methods]) => { + if (!includes(methods, 'pki' as MfaMethod) || !get(pkiEndpoint)) { + set(publicKeys, []) + return + } - const mfaEndabledMethods = shallowRef<MfaMethod[]>([]) - const pkiPublicKeys = shallowRef<PkiPublicKey[]>([]) + //load the pki keys if pki is enabled + apiCall(async () => publicKeys.value = await pkiConfig.getAllKeys()) + }) + + return{ + publicKeys, + pkiConfig, + pkiAuth, + refresh + } + } watch([loggedIn, onRefresh], ([ li ]) => { if(!li){ - mfaEndabledMethods.value = [] + set(enabledMethods, []) return } //load the mfa methods if the user is logged in - apiCall(async () => mfaEndabledMethods.value = await mfaConfig.getMethods()) - }) - - //Watch for changes to mfa methods (refresh) and update the pki keys - watch([mfaEndabledMethods], ([ methods ]) => { - if(!includes(methods, 'pki' as MfaMethod) || !get(pkiEndpoint)){ - pkiPublicKeys.value = [] - return - } - - //load the pki keys if pki is enabled - apiCall(async () => pkiPublicKeys.value = await pkiConfig.getAllKeys()) + apiCall(async () => enabledMethods.value = await mfaConfig.getMethods()) }) - return{ - mfaRefreshMethods, - mfaEndabledMethods, - mfaConfig, - pkiConfig, - pkiAuth, - pkiPublicKeys + //Only return the pki store if pki is enabled + if(get(pkiEndpoint)){ + return storeExport({ + mfa:{ + enabledMethods, + refresh, + ...mfaConfig + }, + pki: usePki() + }) + } + else{ + return storeExport({ + mfa:{ + enabledMethods, + refresh, + ...mfaConfig + }, + }) + } } }
\ No newline at end of file diff --git a/front-end/src/store/oauthAppsPlugin.ts b/front-end/src/store/oauthAppsPlugin.ts new file mode 100644 index 0000000..7a76992 --- /dev/null +++ b/front-end/src/store/oauthAppsPlugin.ts @@ -0,0 +1,154 @@ +import 'pinia' +import { MaybeRef, computed, ref, shallowRef, watch } from 'vue'; +import { apiCall, useAxios } from '@vnuge/vnlib.browser'; +import { get, set, useToggle } from '@vueuse/core'; +import { PiniaPlugin, PiniaPluginContext, storeToRefs } from 'pinia' +import { map, sortBy, isArray } from 'lodash-es'; +import { storeExport } from '.'; + +export interface OAuth2Application { + readonly Id: string, + readonly name: string, + readonly description: string, + readonly permissions: string[], + readonly client_id: string, + Created: Date, + readonly LastModified: Date, +} + +export interface NewAppResponse { + readonly secret: string + readonly app: OAuth2Application +} + +export interface Oauth2Store{ + oauth2: { + apps: OAuth2Application[], + scopes: string[], + getApps(): Promise<OAuth2Application[]> + createApp(app: OAuth2Application): Promise<NewAppResponse> + updateAppSecret(app: OAuth2Application, password: string): Promise<string> + updateAppMeta(app: OAuth2Application): Promise<void> + deleteApp(app: OAuth2Application, password: string): Promise<void> + refresh(): void + } +} + +declare module 'pinia' { + export interface PiniaCustomProperties extends Oauth2Store{ + + } +} + +export const oauth2AppsPlugin = (o2EndpointUrl: MaybeRef<string>, scopeEndpoint: MaybeRef<string>): PiniaPlugin =>{ + + return ({ store }: PiniaPluginContext): Oauth2Store => { + + const axios = useAxios(null); + const { loggedIn } = storeToRefs(store) + + const [onRefresh, refresh] = useToggle() + + const _oauth2Apps = shallowRef<OAuth2Application[]>([]) + const scopes = ref<string[]>([]) + + /** + * Updates an Oauth2 application's metadata + */ + const updateAppMeta = async (app: OAuth2Application): Promise<void> => { + //Update the app metadata + await axios.put(get(o2EndpointUrl), app) + } + + /** + * Gets all of the user's oauth2 applications from the server + * @returns The user's oauth2 applications + */ + const getApps = async () => { + // Get all apps + const { data } = await axios.get<OAuth2Application[]>(get(o2EndpointUrl)); + + if(!isArray(data)){ + throw new Error("Invalid response from server") + } + + return map(data, (appData) => { + //Store the created time as a date object + appData.Created = new Date(appData?.Created ?? 0) + //create a new state manager for the user's profile + return appData; + }) + } + + /** + * Creates a new application from the given data + * @param param0 The application server buffer + * @returns The newly created application + */ + const createApp = async ({ name, description, permissions }: OAuth2Application): Promise<NewAppResponse> => { + + // make the post request, response is the new app data with a secret + const { data } = await axios.post<OAuth2Application & { raw_secret: string }>(`${get(o2EndpointUrl)}?action=create`, { name, description, permissions }) + + // Store secret + const secret = data.raw_secret + + // remove secre tfrom the response + delete (data as any).raw_secret + + return { secret, app: data } + } + + /** + * Requets a new secret for an application from the server + * @param app The app to request a new secret for + * @param password The user's password + * @returns The new secret + */ + const updateAppSecret = async (app: OAuth2Application, password: string): Promise<string> => { + const { data } = await axios.post(`${o2EndpointUrl}?action=secret`, { Id: app.Id, password }) + return data.raw_secret + } + + /** + * Deletes an application from the server + * @param app The application to delete + * @param password The user's password + * @returns The response from the server + */ + const deleteApp = async ({ Id }: OAuth2Application, password: string): Promise<void> => { + await axios.post(`${o2EndpointUrl}?action=delete`, { password, Id }); + } + + const apps = computed(() => sortBy(_oauth2Apps.value, a => a.Created)) + + watch([loggedIn, onRefresh], async ([li]) => { + if (!li){ + set(_oauth2Apps, []) + return; + } + + //Load the user's oauth2 apps + apiCall(async () => { + _oauth2Apps.value = await getApps() + + //Load the oauth2 scopes + const { data } = await axios.get<string[]>(get(scopeEndpoint)) + set(scopes, data) + }) + }) + + return storeExport({ + oauth2:{ + apps, + scopes, + getApps, + createApp, + updateAppMeta, + updateAppSecret, + deleteApp, + refresh + } + }) + } +}
\ No newline at end of file diff --git a/front-end/src/store/pageProtectionPlugin.ts b/front-end/src/store/pageProtectionPlugin.ts index 9831dad..a747e49 100644 --- a/front-end/src/store/pageProtectionPlugin.ts +++ b/front-end/src/store/pageProtectionPlugin.ts @@ -60,14 +60,12 @@ export const pageProtectionPlugin = (router: ReturnType<typeof useRouter>): Pini return true; }) - router.afterEach(() => { - //scroll window back to top - window.scrollTo(0, 0) - }) + //scroll window back to top + router.afterEach(() => window.scrollTo(0, 0)) - watch(loggedIn, (loggedIn) => { + watch(loggedIn, (li) => { //If the user gets logged out, redirect to login - if(loggedIn === false && router.currentRoute.value.name !== 'Login'){ + if(li === false && router.currentRoute.value.name !== 'Login'){ router.push({ name: 'Login' }) } }) diff --git a/front-end/src/store/socialMfaPlugin.ts b/front-end/src/store/socialMfaPlugin.ts index b9bce27..3968cf1 100644 --- a/front-end/src/store/socialMfaPlugin.ts +++ b/front-end/src/store/socialMfaPlugin.ts @@ -1,3 +1,4 @@ + import 'pinia' import { MaybeRef } from 'vue'; import { useSocialOauthLogin, useUser, SocialOAuthPortal, fromPortals, useAxios } from '@vnuge/vnlib.browser' @@ -34,30 +35,42 @@ export const socialMfaPlugin = (portalEndpoint?: MaybeRef<string>): PiniaPlugin } } - const _loadPromise = new Promise<SocialMfaPlugin>((resolve, reject) => { + const _loadPromise = new Promise<SocialMfaPlugin>((resolve, _) => { - if(get(portalEndpoint) == null) { + if (get(portalEndpoint) == null) { const socialOauth = useSocialOauthLogin([]) setLogoutMethod(socialOauth) return resolve(socialOauth) } + /* + Try to load social methods from server, if it fails, then we will + fall back to default + */ + defer(async () => { + + let portals: SocialOAuthPortal[] = [] + try { //Get axios instance const axios = useAxios(null) //Get all enabled portals - const { data } = await axios.get<SocialOAuthPortal[]>(get(portalEndpoint)); - //Setup social providers from server portals - const socialOauth = useSocialOauthLogin(fromPortals(data)); - setLogoutMethod(socialOauth); - - resolve(socialOauth) + const { data, headers } = await axios.get<SocialOAuthPortal[]>(get(portalEndpoint)!); + + if(headers['content-type'] === 'application/json') { + portals = data + } } catch (error) { - reject(error) + //Let failure fall back to default } + + //Create social login from available portals + const socialOauth = useSocialOauthLogin(fromPortals(portals)); + setLogoutMethod(socialOauth); + resolve(socialOauth) }) }) diff --git a/front-end/src/store/userProfile.ts b/front-end/src/store/userProfile.ts index a4ea469..0320ace 100644 --- a/front-end/src/store/userProfile.ts +++ b/front-end/src/store/userProfile.ts @@ -3,7 +3,8 @@ import { MaybeRef, watch } from 'vue'; import { ServerDataBuffer, ServerObjectBuffer, UserProfile, WebMessage, apiCall, useAxios, useDataBuffer, useUser } from '@vnuge/vnlib.browser'; import { get, useToggle } from '@vueuse/core'; import { PiniaPlugin, PiniaPluginContext, storeToRefs } from 'pinia' -import { defer } from 'lodash-es'; +import { defer, noop } from 'lodash-es'; +import { storeExport } from './index'; export interface OAuth2Application { readonly Id: string, @@ -24,17 +25,21 @@ interface ExUserProfile extends UserProfile { created: string | Date } +export interface UserProfileStore{ + userProfile: ServerDataBuffer<ExUserProfile, WebMessage<string>> + userName: string | undefined + refreshProfile(): void; +} + declare module 'pinia' { - export interface PiniaCustomProperties { - userProfile: ServerDataBuffer<ExUserProfile, WebMessage<string>> - userName: string | undefined - refreshProfile(): void; + export interface PiniaCustomProperties extends UserProfileStore { + } } export const profilePlugin = (accountsUrl:MaybeRef<string>) :PiniaPlugin => { - return ({ store }: PiniaPluginContext) => { + return ({ store }: PiniaPluginContext): UserProfileStore => { const { loggedIn } = storeToRefs(store) const { getProfile, userName } = useUser() @@ -64,19 +69,16 @@ export const profilePlugin = (accountsUrl:MaybeRef<string>) :PiniaPlugin => { userProfile.apply(profile) } - watch([loggedIn, onRefresh], ([li]) => { - //If the user is logged in, load the profile buffer - if (li) { - apiCall(loadProfile) - } - }) + //If the user is logged in, load the profile buffer + watch([loggedIn, onRefresh], ([li]) => li ? apiCall(loadProfile) : noop()) + //Defer intiial profile load defer(refreshProfile); - return { + return storeExport({ userProfile, refreshProfile, userName - } + }) } }
\ No newline at end of file |