feat: introducing Auth for admin dashboard (HBE-138) (#32)

This commit is contained in:
Anwarul Islam
2023-03-09 11:29:40 +06:00
committed by GitHub
parent 80898407c3
commit 9b76d62753
29 changed files with 1846 additions and 765 deletions

View File

@@ -5,9 +5,10 @@
</template>
<script setup lang="ts">
import { useDark, useToggle } from '@vueuse/core';
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { useDark, useToggle } from '@vueuse/core';
import { HOPP_MODULES } from './modules';
const defaultLayout = 'default';
@@ -18,7 +19,10 @@ const layout = computed(
);
const isDark = useDark();
const toggleDark = useToggle(isDark);
useToggle(isDark);
// Run module root component setup code
HOPP_MODULES.forEach((mod) => mod.onRootSetup?.());
</script>
<style lang="scss">

View File

@@ -8,51 +8,31 @@ export {}
declare module '@vue/runtime-core' {
export interface GlobalComponents {
AppHeader: typeof import('./components/app/Header.vue')['default']
AppLogin: typeof import('./components/app/Login.vue')['default']
AppLogout: typeof import('./components/app/Logout.vue')['default']
AppModal: typeof import('./components/app/Modal.vue')['default']
AppSidebar: typeof import('./components/app/Sidebar.vue')['default']
AppToast: typeof import('./components/app/Toast.vue')['default']
ButtonPrimary: typeof import('./../../hoppscotch-ui/src/components/button/Primary.vue')['default']
ButtonSecondary: typeof import('./../../hoppscotch-ui/src/components/button/Secondary.vue')['default']
HoppButtonPrimary: typeof import('@hoppscotch/ui')['HoppButtonPrimary']
HoppButtonSecondary: typeof import('@hoppscotch/ui')['HoppButtonSecondary']
HoppSmartAnchor: typeof import('@hoppscotch/ui')['HoppSmartAnchor']
HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal']
HoppSmartModal: typeof import('@hoppscotch/ui')['HoppSmartModal']
HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem']
IconLucideBell: typeof import('~icons/lucide/bell')['default']
IconLucideChevronDown: typeof import('~icons/lucide/chevron-down')['default']
IconLucideChevronLeft: typeof import('~icons/lucide/chevron-left')['default']
IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
IconLucideInbox: typeof import('~icons/lucide/inbox')['default']
IconLucideLayoutDashboard: typeof import('~icons/lucide/layout-dashboard')['default']
IconLucideLineChart: typeof import('~icons/lucide/line-chart')['default']
IconLucideLock: typeof import('~icons/lucide/lock')['default']
IconLucideMenu: typeof import('~icons/lucide/menu')['default']
IconLucideMoreHorizontal: typeof import('~icons/lucide/more-horizontal')['default']
IconLucideSettings: typeof import('~icons/lucide/settings')['default']
IconLucideSidebarClose: typeof import('~icons/lucide/sidebar-close')['default']
IconLucideSidebarOpen: typeof import('~icons/lucide/sidebar-open')['default']
IconLucideUser: typeof import('~icons/lucide/user')['default']
IconLucideUserCog: typeof import('~icons/lucide/user-cog')['default']
IconLucideUsers: typeof import('~icons/lucide/users')['default']
ProfilePicture: typeof import('./components/profile/Picture.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SmartAnchor: typeof import('./../../hoppscotch-ui/src/components/smart/Anchor.vue')['default']
SmartAutoComplete: typeof import('./../../hoppscotch-ui/src/components/smart/AutoComplete.vue')['default']
SmartCheckbox: typeof import('./../../hoppscotch-ui/src/components/smart/Checkbox.vue')['default']
SmartConfirmModal: typeof import('./../../hoppscotch-ui/src/components/smart/ConfirmModal.vue')['default']
SmartExpand: typeof import('./../../hoppscotch-ui/src/components/smart/Expand.vue')['default']
SmartFileChip: typeof import('./../../hoppscotch-ui/src/components/smart/FileChip.vue')['default']
SmartIntersection: typeof import('./../../hoppscotch-ui/src/components/smart/Intersection.vue')['default']
SmartItem: typeof import('./../../hoppscotch-ui/src/components/smart/Item.vue')['default']
SmartLink: typeof import('./../../hoppscotch-ui/src/components/smart/Link.vue')['default']
SmartModal: typeof import('./../../hoppscotch-ui/src/components/smart/Modal.vue')['default']
SmartProgressRing: typeof import('./../../hoppscotch-ui/src/components/smart/ProgressRing.vue')['default']
SmartRadio: typeof import('./../../hoppscotch-ui/src/components/smart/Radio.vue')['default']
SmartRadioGroup: typeof import('./../../hoppscotch-ui/src/components/smart/RadioGroup.vue')['default']
SmartSlideOver: typeof import('./../../hoppscotch-ui/src/components/smart/SlideOver.vue')['default']
SmartSpinner: typeof import('./../../hoppscotch-ui/src/components/smart/Spinner.vue')['default']
SmartTab: typeof import('./../../hoppscotch-ui/src/components/smart/Tab.vue')['default']
SmartTabs: typeof import('./../../hoppscotch-ui/src/components/smart/Tabs.vue')['default']
SmartToggle: typeof import('./../../hoppscotch-ui/src/components/smart/Toggle.vue')['default']
SmartWindow: typeof import('./../../hoppscotch-ui/src/components/smart/Window.vue')['default']
SmartWindows: typeof import('./../../hoppscotch-ui/src/components/smart/Windows.vue')['default']
TeamsAddMembers: typeof import('./components/teams/AddMembers.vue')['default']
UsersDetails: typeof import('./components/users/Details.vue')['default']
}

View File

@@ -26,15 +26,25 @@
<icon-lucide-bell class="text-gray-300 w-6" />
</button>
<div class="relative">
<div v-if="currentUser" class="relative">
<button
@click="dropdownOpen = !dropdownOpen"
class="relative z-10 block w-8 h-8 overflow-hidden rounded-full shadow focus:outline-none"
>
<img
class="object-cover w-full h-full"
src="https://media.licdn.com/dms/image/C5603AQHMCx72bNN1MA/profile-displayphoto-shrink_800_800/0/1630736416611?e=2147483647&v=beta&t=McWCdK_7t_NLeU4ze3JPB8xvwg5w50Okuj2JDBekqjw"
alt="Your avatar"
<ProfilePicture
v-if="currentUser.photoURL"
v-tippy="{
theme: 'tooltip',
}"
:url="currentUser.photoURL"
:alt="currentUser.displayName ?? 'No Name'"
:title="currentUser.displayName ?? currentUser.email ?? 'No Name'"
/>
<ProfilePicture
v-else
v-tippy="{ theme: 'tooltip' }"
:title="currentUser.displayName ?? currentUser.email ?? 'No Name'"
:initial="currentUser.displayName ?? currentUser.email"
/>
</button>
@@ -56,21 +66,13 @@
v-show="dropdownOpen"
class="absolute right-0 z-20 w-48 py-2 mt-2 bg-zinc-200 dark:bg-zinc-800 rounded-md shadow-xl"
>
<a
href="#"
class="block px-4 py-2 text-sm text-gray-800 dark:text-gray-200 hover:bg-emerald-700 hover:text-white"
>Profile</a
>
<a
href="#"
class="block px-4 py-2 text-sm text-gray-800 dark:text-gray-200 hover:bg-emerald-700 hover:text-white"
>Settings</a
>
<router-link
to="/"
class="block px-4 py-2 text-sm text-gray-800 dark:text-gray-200 hover:bg-emerald-700 hover:text-white"
>Log out</router-link
>
<HoppSmartItem to="/profile" :icon="IconUser" :label="'Profile'" />
<HoppSmartItem
to="/settings"
:icon="IconSettings"
:label="'Settings'"
/>
<AppLogout ref="logout" />
</div>
</transition>
</div>
@@ -79,11 +81,20 @@
</template>
<script setup lang="ts">
import IconSettings from '~icons/lucide/settings';
import IconUser from '~icons/lucide/user';
import { ref } from 'vue';
import { useSidebar } from '../../composables/useSidebar';
import { useReadonlyStream } from '~/composables/stream';
import { useSidebar } from '~/composables/useSidebar';
import { auth } from '~/helpers/auth';
const { isOpen, isExpanded } = useSidebar();
const currentUser = useReadonlyStream(
auth.getProbableUserStream(),
auth.getProbableUser()
);
const expandSidebar = () => {
isExpanded.value = !isExpanded.value;
};

View File

@@ -0,0 +1,236 @@
<template>
<div v-if="nonAdminUser" class="text-center">
Logged in as non admin user. Please
<span @click="logout()" class="text-red-500 cursor-pointer underline"
>sign out</span
>
and login with an admin account.
</div>
<div v-else>
<div v-if="mode === 'sign-in'" class="flex flex-col space-y-2">
<HoppSmartItem
:loading="signingInWithGitHub"
:icon="IconGithub"
:label="`Continue with GitHub`"
@click="signInWithGithub"
/>
<HoppSmartItem
:loading="signingInWithGoogle"
:icon="IconGoogle"
:label="`Continue with Google`"
@click="signInWithGoogle"
/>
<HoppSmartItem
:loading="signingInWithMicrosoft"
:icon="IconMicrosoft"
:label="`Continue with Microsoft`"
@click="signInWithMicrosoft"
/>
<HoppSmartItem
:icon="IconEmail"
:label="`Continue with Email`"
@click="mode = 'email'"
/>
</div>
<form
v-if="mode === 'email'"
class="flex flex-col space-y-2"
@submit.prevent="signInWithEmail"
>
<div class="flex flex-col">
<input
id="email"
v-model="form.email"
class="input floating-input"
placeholder=" "
type="email"
name="email"
autocomplete="off"
required
spellcheck="false"
v-focus
autofocus
/>
<label for="email"> Email </label>
</div>
<HoppButtonPrimary
:loading="signingInWithEmail"
type="submit"
:label="`Send magic link`"
/>
</form>
<div v-if="mode === 'email-sent'" class="flex flex-col px-4">
<div class="flex flex-col items-center justify-center max-w-md">
<icon-lucide-inbox class="w-6 h-6 text-accent" />
<h3 class="my-2 text-lg text-center">
We sent a magic link to {{ form.email }}
</h3>
<p class="text-center">
We sent a magic link to {{ form.email }}. Click on the link to sign
in.
</p>
</div>
</div>
<section class="text-center mt-10">
<div v-if="mode === 'sign-in'" class="text-secondaryLight text-tiny">
By signing in, you are agreeing to our
<HoppSmartAnchor
class="link"
to="https://docs.hoppscotch.io/terms"
blank
label="Terms of Service"
/>
and
<HoppSmartAnchor
class="link"
to="https://docs.hoppscotch.io/privacy"
blank
label="Privacy Policy"
/>
</div>
<div v-if="mode === 'email'">
<HoppButtonSecondary
:label="'All sign in option'"
:icon="IconArrowLeft"
class="!p-0"
@click="mode = 'sign-in'"
/>
</div>
<div
v-if="mode === 'email-sent'"
class="flex justify-between flex-1 text-secondaryLight"
>
<HoppSmartAnchor
class="link"
:label="'Re enter email'"
:icon="IconArrowLeft"
@click="mode = 'email'"
/>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import IconGithub from '~icons/auth/github';
import IconGoogle from '~icons/auth/google';
import IconEmail from '~icons/auth/email';
import IconMicrosoft from '~icons/auth/microsoft';
import IconArrowLeft from '~icons/lucide/arrow-left';
import { setLocalConfig } from '~/helpers/localpersistence';
import { useStreamSubscriber } from '~/composables/stream';
import { useToast } from '~/composables/toast';
import { auth } from '~/helpers/auth';
const { subscribeToStream } = useStreamSubscriber();
const toast = useToast();
// DATA
const form = ref({
email: '',
});
const signingInWithGoogle = ref(false);
const signingInWithGitHub = ref(false);
const signingInWithMicrosoft = ref(false);
const signingInWithEmail = ref(false);
const mode = ref('sign-in');
const nonAdminUser = ref(false);
onMounted(() => {
const currentUser$ = auth.getCurrentUserStream();
subscribeToStream(currentUser$, (user) => {
if (user && !user.isAdmin) {
nonAdminUser.value = true;
toast.error(`You are logged in. But you're not an admin`);
}
});
});
async function signInWithGoogle() {
signingInWithGoogle.value = true;
try {
await auth.signInUserWithGoogle();
} catch (e) {
console.error(e);
/*
A auth/account-exists-with-different-credential Firebase error wont happen between Google and any other providers
Seems Google account overwrites accounts of other providers https://github.com/firebase/firebase-android-sdk/issues/25
*/
toast.error(`Failed to sign in with Google`);
}
signingInWithGoogle.value = false;
}
async function signInWithGithub() {
signingInWithGitHub.value = true;
try {
await auth.signInUserWithGithub();
} catch (e) {
console.error(e);
/*
A auth/account-exists-with-different-credential Firebase error wont happen between Google and any other providers
Seems Google account overwrites accounts of other providers https://github.com/firebase/firebase-android-sdk/issues/25
*/
toast.error(`Failed to sign in with GitHub`);
}
signingInWithGitHub.value = false;
}
async function signInWithMicrosoft() {
signingInWithMicrosoft.value = true;
try {
await auth.signInUserWithMicrosoft();
} catch (e) {
console.error(e);
/*
A auth/account-exists-with-different-credential Firebase error wont happen between MS with Google or Github
If a Github account exists and user then logs in with MS email we get a "Something went wrong toast" and console errors and MS replaces GH as only provider.
The error messages are as follows:
FirebaseError: Firebase: Error (auth/popup-closed-by-user).
@firebase/auth: Auth (9.6.11): INTERNAL ASSERTION FAILED: Pending promise was never set
They may be related to https://github.com/firebase/firebaseui-web/issues/947
*/
toast.error(`Something went wrong`);
}
signingInWithMicrosoft.value = false;
}
async function signInWithEmail() {
signingInWithEmail.value = true;
await auth
.signInWithEmail(form.value.email)
.then(() => {
mode.value = 'email-sent';
setLocalConfig('emailForSignIn', form.value.email);
})
.catch((e: any) => {
console.error(e);
toast.error(e.message);
signingInWithEmail.value = false;
})
.finally(() => {
signingInWithEmail.value = false;
});
}
const logout = async () => {
try {
await auth.signOutUser();
window.location.reload();
toast.success(`Logged out`);
} catch (e) {
console.error(e);
toast.error(`Something went wrong`);
}
};
</script>

View File

@@ -0,0 +1,62 @@
<template>
<div class="flex" @click="openLogoutModal()">
<HoppSmartItem
:icon="IconLogOut"
:label="'Logout'"
:outline="outline"
:shortcut="shortcut"
@click="openLogoutModal()"
/>
<HoppSmartConfirmModal
:show="confirmLogout"
:title="`Confirm Logout`"
@hide-modal="confirmLogout = false"
@resolve="logout"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import IconLogOut from '~icons/lucide/log-out';
import { useToast } from '~/composables/toast';
import { useRouter } from 'vue-router';
import { auth } from '~/helpers/auth';
const router = useRouter();
defineProps({
outline: {
type: Boolean,
default: false,
},
shortcut: {
type: Array,
default: () => [],
},
});
const emit = defineEmits<{
(e: 'confirm-logout'): void;
}>();
const confirmLogout = ref(false);
const toast = useToast();
const logout = async () => {
try {
await auth.signOutUser();
router.push(`/`);
toast.success(`Logged out`);
} catch (e) {
console.error(e);
toast.error(`Something went wrong`);
}
};
const openLogoutModal = () => {
emit('confirm-logout');
confirmLogout.value = true;
};
</script>

View File

@@ -0,0 +1,87 @@
<template>
<div
tabindex="0"
class="relative flex items-center justify-center cursor-pointer focus:outline-none focus-visible:ring focus-visible:ring-primaryDark"
:class="[`rounded-${rounded}`, `w-${size} h-${size}`]"
>
<img
v-if="url"
class="absolute object-cover object-center transition bg-primaryDark"
:class="[`rounded-${rounded}`, `w-${size} h-${size}`]"
:src="url"
:alt="alt"
loading="lazy"
/>
<div
v-else
class="absolute flex items-center justify-center object-cover object-center transition bg-primaryDark text-accentContrast"
:class="[`rounded-${rounded}`, `w-${size} h-${size}`]"
:style="`background-color: ${initial ? toHex(initial) : '#480000'}`"
>
<template v-if="initial && initial.charAt(0).toUpperCase()">
{{ initial.charAt(0).toUpperCase() }}
</template>
<icon-lucide-user v-else></icon-lucide-user>
</div>
<span
v-if="indicator"
class="border-primary border-2 h-2.5 -top-0.5 -right-0.5 w-2.5 absolute"
:class="[`rounded-${rounded}`, indicatorStyles]"
></span>
<!-- w-5 h-5 rounded-lg -->
</div>
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue';
export default defineComponent({
props: {
url: {
type: String,
default: '',
},
alt: {
type: String,
default: 'Profile picture',
},
indicator: {
type: Boolean,
default: false,
},
indicatorStyles: {
type: String,
default: 'bg-green-500',
},
rounded: {
type: String,
default: 'full',
},
size: {
type: String,
default: '5',
},
initial: {
type: String as PropType<string | undefined | null>,
default: '',
},
},
methods: {
toHex(initial: string) {
let hash = 0;
if (initial.length === 0) return hash;
for (let i = 0; i < initial.length; i++) {
hash = initial.charCodeAt(i) + ((hash << 5) - hash);
hash = hash & hash;
}
let color = '#';
for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 255;
color += `00${value.toString(16)}`.slice(-2);
}
return color;
},
},
});
</script>

View File

@@ -0,0 +1,62 @@
import { platform } from '~/platform';
import { AuthEvent, HoppUser } from '~/platform/auth';
import { Subscription } from 'rxjs';
import { onBeforeUnmount, onMounted, watch, WatchStopHandle } from 'vue';
import { useReadonlyStream } from './stream';
/**
* A Vue composable function that is called when the auth status
* is being updated to being logged in (fired multiple times),
* this is also called on component mount if the login
* was already resolved before mount.
*/
export function onLoggedIn(exec: (user: HoppUser) => void) {
const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser()
);
let watchStop: WatchStopHandle | null = null;
onMounted(() => {
if (currentUser.value) exec(currentUser.value);
watchStop = watch(currentUser, (newVal, prev) => {
if (prev === null && newVal !== null) {
exec(newVal);
}
});
});
onBeforeUnmount(() => {
watchStop?.();
});
}
/**
* A Vue composable function that calls its param function
* when a new event (login, logout etc.) happens in
* the auth system.
*
* NOTE: Unlike `onLoggedIn` for which the callback will be called once on mount with the current state,
* here the callback will only be called on authentication event occurances.
* You might want to check the auth state from an `onMounted` hook or something
* if you want to access the initial state
*
* @param func A function which accepts an event
*/
export function onAuthEvent(func: (ev: AuthEvent) => void) {
const authEvents$ = platform.auth.getAuthEventsStream();
let sub: Subscription | null = null;
onMounted(() => {
sub = authEvents$.subscribe((ev) => {
func(ev);
});
});
onBeforeUnmount(() => {
sub?.unsubscribe();
});
}

View File

@@ -0,0 +1,174 @@
import { clone, cloneDeep } from 'lodash-es';
import { Observable, Subscription } from 'rxjs';
import { customRef, onBeforeUnmount, readonly, Ref } from 'vue';
type CloneMode = 'noclone' | 'shallow' | 'deep';
/**
* Returns a readonly (no writes) ref for an RxJS Observable
* @param stream$ The RxJS Observable to listen to
* @param initialValue The initial value to apply until the stream emits a value
* @param cloneMode Determines whether or not and how deep to clone the emitted value.
* Useful for issues in reactivity due to reference sharing. Defaults to shallow clone
* @returns A readonly ref which has the latest value from the stream
*/
export function useReadonlyStream<T>(
stream$: Observable<T>,
initialValue: T,
cloneMode: CloneMode = 'shallow'
): Ref<T> {
let sub: Subscription | null = null;
onBeforeUnmount(() => {
if (sub) {
sub.unsubscribe();
}
});
const r = customRef((track, trigger) => {
let val = initialValue;
sub = stream$.subscribe((value) => {
if (cloneMode === 'noclone') {
val = value;
} else if (cloneMode === 'shallow') {
val = clone(value);
} else if (cloneMode === 'deep') {
val = cloneDeep(value);
}
trigger();
});
return {
get() {
track();
return val;
},
set() {
trigger(); // <- Not exactly needed here
throw new Error('Cannot write to a ref from useReadonlyStream');
},
};
});
// Casting to still maintain the proper type signature for ease of use
return readonly(r) as Ref<T>;
}
export function useStream<T>(
stream$: Observable<T>,
initialValue: T,
setter: (val: T) => void
) {
let sub: Subscription | null = null;
onBeforeUnmount(() => {
if (sub) {
sub.unsubscribe();
}
});
return customRef((track, trigger) => {
let value = initialValue;
sub = stream$.subscribe((val) => {
value = val;
trigger();
});
return {
get() {
track();
return value;
},
set(value: T) {
trigger();
setter(value);
},
};
});
}
/** A static (doesn't cleanup on itself and does
* not require component instace) version of useStream
*/
export function useStreamStatic<T>(
stream$: Observable<T>,
initialValue: T,
setter: (val: T) => void
): [Ref<T>, () => void] {
let sub: Subscription | null = null;
const stopper = () => {
if (sub) {
sub.unsubscribe();
}
};
return [
customRef((track, trigger) => {
let value = initialValue;
sub = stream$.subscribe((val) => {
value = val;
trigger();
});
return {
get() {
track();
return value;
},
set(value: T) {
trigger();
setter(value);
},
};
}),
stopper,
];
}
export type StreamSubscriberFunc = <T>(
stream: Observable<T>,
next?: ((value: T) => void) | undefined,
error?: ((e: any) => void) | undefined,
complete?: (() => void) | undefined
) => void;
/**
* A composable that provides the ability to run streams
* and subscribe to them and respect the component lifecycle.
*/
export function useStreamSubscriber(): {
subscribeToStream: StreamSubscriberFunc;
} {
const subs: Subscription[] = [];
const runAndSubscribe = <T>(
stream: Observable<T>,
next?: (value: T) => void,
error?: (e: any) => void,
complete?: () => void
) => {
const sub = stream.subscribe({
next,
error,
complete: () => {
if (complete) complete();
subs.splice(subs.indexOf(sub), 1);
},
});
subs.push(sub);
};
onBeforeUnmount(() => {
subs.forEach((sub) => sub.unsubscribe());
});
return {
subscribeToStream: runAndSubscribe,
};
}

View File

@@ -0,0 +1,385 @@
import axios from 'axios';
import { BehaviorSubject, Subject } from 'rxjs';
import {
getLocalConfig,
removeLocalConfig,
setLocalConfig,
} from './localpersistence';
import { Ref, ref, watch } from 'vue';
/**
* A common (and required) set of fields that describe a user.
*/
export type HoppUser = {
/** A unique ID identifying the user */
uid: string;
/** The name to be displayed as the user's */
displayName: string | null;
/** The user's email address */
email: string | null;
/** URL to the profile picture of the user */
photoURL: string | null;
// Regarding `provider` and `accessToken`:
// The current implementation and use case for these 2 fields are super weird due to legacy.
// Currrently these fields are only basically populated for Github Auth as we need the access token issued
// by it to implement Gist submission. I would really love refactor to make this thing more sane.
/** Name of the provider authenticating (NOTE: See notes on `platform/auth.ts`) */
provider?: string;
/** Access Token for the auth of the user against the given `provider`. */
accessToken?: string;
emailVerified: boolean;
isAdmin: boolean;
};
export type AuthEvent =
| { event: 'probable_login'; user: HoppUser } // We have previous login state, but the app is waiting for authentication
| { event: 'login'; user: HoppUser } // We are authenticated
| { event: 'logout' } // No authentication and we have no previous state
| { event: 'token_refresh' }; // We have previous login state, but the app is waiting for authentication
export type GithubSignInResult =
| { type: 'success'; user: HoppUser } // The authentication was a success
| { type: 'account-exists-with-different-cred'; link: () => Promise<void> } // We authenticated correctly, but the provider didn't match, so we give the user the opportunity to link to continue completing auth
| { type: 'error'; err: unknown }; // Auth failed completely and we don't know why
export const authEvents$ = new Subject<
AuthEvent | { event: 'token_refresh' }
>();
const currentUser$ = new BehaviorSubject<HoppUser | null>(null);
export const probableUser$ = new BehaviorSubject<HoppUser | null>(null);
async function logout() {
await axios.get(`${import.meta.env.VITE_BACKEND_API_URL}/auth/logout`, {
withCredentials: true,
});
}
async function signInUserWithGithubFB() {
window.location.href = `${import.meta.env.VITE_BACKEND_API_URL}/auth/github`;
}
async function signInUserWithGoogleFB() {
window.location.href = `${import.meta.env.VITE_BACKEND_API_URL}/auth/google`;
}
async function signInUserWithMicrosoftFB() {
window.location.href = `${
import.meta.env.VITE_BACKEND_API_URL
}/auth/microsoft`;
}
async function getInitialUserDetails() {
const res = await axios.post<{
data?: {
me?: {
uid: string;
displayName: string;
email: string;
photoURL: string;
isAdmin: boolean;
createdOn: string;
// emailVerified: boolean
};
};
errors?: Array<{
message: string;
}>;
}>(
`${import.meta.env.VITE_BACKEND_GQL_URL}`,
{
query: `query Me {
me {
uid
displayName
email
photoURL
isAdmin
createdOn
}
}`,
},
{
headers: {
'Content-Type': 'application/json',
},
withCredentials: true,
}
);
return res.data;
}
const isGettingInitialUser: Ref<null | boolean> = ref(null);
function setUser(user: HoppUser | null) {
currentUser$.next(user);
probableUser$.next(user);
setLocalConfig('login_state', JSON.stringify(user));
}
async function setInitialUser() {
isGettingInitialUser.value = true;
const res = await getInitialUserDetails();
const error = res.errors && res.errors[0];
// no cookies sent. so the user is not logged in
if (error && error.message === 'auth/cookies_not_found') {
setUser(null);
isGettingInitialUser.value = false;
return;
}
// cookies sent, but it is expired, we need to refresh the token
if (error && error.message === 'Unauthorized') {
const isRefreshSuccess = await refreshToken();
if (isRefreshSuccess) {
setInitialUser();
} else {
setUser(null);
isGettingInitialUser.value = false;
}
return;
}
// no errors, we have a valid user
if (res.data && res.data.me) {
const hoppBackendUser = res.data.me;
const hoppUser: HoppUser = {
uid: hoppBackendUser.uid,
displayName: hoppBackendUser.displayName,
email: hoppBackendUser.email,
photoURL: hoppBackendUser.photoURL,
// all our signin methods currently guarantees the email is verified
emailVerified: true,
isAdmin: hoppBackendUser.isAdmin,
};
if (!hoppUser.isAdmin) {
const isAdmin = await elevateUser();
hoppUser.isAdmin = isAdmin;
}
setUser(hoppUser);
isGettingInitialUser.value = false;
authEvents$.next({
event: 'login',
user: hoppUser,
});
return;
}
}
async function refreshToken() {
const res = await axios.get(
`${import.meta.env.VITE_BACKEND_API_URL}/auth/refresh`,
{
withCredentials: true,
}
);
const isSuccessful = res.status === 200;
if (isSuccessful) {
authEvents$.next({
event: 'token_refresh',
});
}
return isSuccessful;
}
async function elevateUser() {
const res = await axios.get(
`${import.meta.env.VITE_BACKEND_API_URL}/auth/verify/admin`,
{
withCredentials: true,
}
);
return !!res.data?.isAdmin;
}
async function sendMagicLink(email: string) {
const res = await axios.post(
`${import.meta.env.VITE_BACKEND_API_URL}/auth/signin`,
{
email,
},
{
withCredentials: true,
}
);
if (res.data && res.data.deviceIdentifier) {
setLocalConfig('deviceIdentifier', res.data.deviceIdentifier);
} else {
throw new Error('test: does not get device identifier');
}
return res.data;
}
export const auth = {
getCurrentUserStream: () => currentUser$,
getAuthEventsStream: () => authEvents$,
getProbableUserStream: () => probableUser$,
getCurrentUser: () => currentUser$.value,
getProbableUser: () => probableUser$.value,
getBackendHeaders() {
return {};
},
getGQLClientOptions() {
return {
fetchOptions: {
credentials: 'include',
},
};
},
/**
* it is not possible for us to know if the current cookie is expired because we cannot access http-only cookies from js
* hence just returning if the currentUser$ has a value associated with it
*/
willBackendHaveAuthError() {
return !currentUser$.value;
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onBackendGQLClientShouldReconnect(func: () => void) {
authEvents$.subscribe((event) => {
if (
event.event == 'login' ||
event.event == 'logout' ||
event.event == 'token_refresh'
) {
func();
}
});
},
/**
* we cannot access our auth cookies from javascript, so leaving this as null
*/
getDevOptsBackendIDToken() {
return null;
},
async performAuthInit() {
const probableUser = JSON.parse(getLocalConfig('login_state') ?? 'null');
probableUser$.next(probableUser);
await setInitialUser();
},
waitProbableLoginToConfirm() {
return new Promise<void>((resolve, reject) => {
if (this.getCurrentUser()) {
resolve();
}
if (!probableUser$.value) reject(new Error('no_probable_user'));
const unwatch = watch(isGettingInitialUser, (val) => {
if (val === true || val === false) {
resolve();
unwatch();
}
});
});
},
async signInWithEmail(email: string) {
await sendMagicLink(email);
},
isSignInWithEmailLink(url: string) {
const urlObject = new URL(url);
const searchParams = new URLSearchParams(urlObject.search);
return !!searchParams.get('token');
},
async verifyEmailAddress() {
return;
},
async signInUserWithGoogle() {
await signInUserWithGoogleFB();
},
async signInUserWithGithub() {
await signInUserWithGithubFB();
return undefined;
},
async signInUserWithMicrosoft() {
await signInUserWithMicrosoftFB();
},
async signInWithEmailLink(email: string, url: string) {
const urlObject = new URL(url);
const searchParams = new URLSearchParams(urlObject.search);
const token = searchParams.get('token');
const deviceIdentifier = getLocalConfig('deviceIdentifier');
await axios.post(
`${import.meta.env.VITE_BACKEND_API_URL}/auth/verify`,
{
token: token,
deviceIdentifier,
},
{
withCredentials: true,
}
);
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async setEmailAddress(_email: string) {
return;
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async setDisplayName(name: string) {
return;
},
async signOutUser() {
// if (!currentUser$.value) throw new Error("No user has logged in")
await logout();
probableUser$.next(null);
currentUser$.next(null);
removeLocalConfig('login_state');
authEvents$.next({
event: 'logout',
});
},
async processMagicLink() {
if (this.isSignInWithEmailLink(window.location.href)) {
const deviceIdentifier = getLocalConfig('deviceIdentifier');
if (!deviceIdentifier) {
throw new Error(
'Device Identifier not found, you can only signin from the browser you generated the magic link'
);
}
await this.signInWithEmailLink(deviceIdentifier, window.location.href);
removeLocalConfig('deviceIdentifier');
window.location.href = '/';
}
},
};

View File

@@ -0,0 +1,9 @@
query Me {
me {
uid
displayName
photoURL
isAdmin
createdOn
}
}

View File

@@ -0,0 +1,27 @@
/**
* Gets a value in LocalStorage.
*
* NOTE: Use LocalStorage to only store non-reactive simple data
* For more complex data, use stores and connect it to localpersistence
*/
export function getLocalConfig(name: string) {
return window.localStorage.getItem(name);
}
/**
* Sets a value in LocalStorage.
*
* NOTE: Use LocalStorage to only store non-reactive simple data
* For more complex data, use stores and connect it to localpersistence
*/
export function setLocalConfig(key: string, value: string) {
window.localStorage.setItem(key, value);
}
/**
* Clear config value in LocalStorage.
* @param key Key to be cleared
*/
export function removeLocalConfig(key: string) {
window.localStorage.removeItem(key);
}

View File

@@ -1,33 +1,16 @@
import { createApp } from 'vue';
import urql, { createClient } from '@urql/vue';
import App from './App.vue';
import Toasted from '@hoppscotch/vue-toasted';
import type { ToastOptions } from '@hoppscotch/vue-toasted';
// STYLES
import 'virtual:windi.css';
import '@hoppscotch/vue-toasted/style.css';
import '@hoppscotch/ui/style.css';
import '../assets/scss/themes.scss';
import '../assets/scss/styles.scss';
// END STYLES
import {
createRouter,
createWebHashHistory,
createWebHistory,
} from 'vue-router';
import { setupLayouts } from 'virtual:generated-layouts';
import generatedRoutes from 'virtual:generated-pages';
import { plugin as HoppUIPlugin, HoppUIPluginOptions } from '@hoppscotch/ui';
const options: HoppUIPluginOptions = {
/* Define options here */
};
const routes = setupLayouts(generatedRoutes);
import { HOPP_MODULES } from './modules';
import { auth } from './helpers/auth';
const app = createApp(App).use(
urql,
@@ -41,21 +24,10 @@ const app = createApp(App).use(
})
);
// We are using a fork of Vue Toasted (github.com/clayzar/vue-toasted) which is a bit of
// an untrusted fork, we will either want to make our own fork or move to a more stable one
// The original Vue Toasted doesn't support Vue 3 and the OP has been irresponsive.
app.use(Toasted, <ToastOptions>{
position: 'bottom-center',
duration: 3000,
keepOnHover: true,
});
// Initialize auth
await auth.performAuthInit();
app.use(HoppUIPlugin, options);
app.use(
createRouter({
history: createWebHistory(),
routes,
})
);
// Initialize modules
HOPP_MODULES.forEach((mod) => mod.onVueAppInit?.(app));
app.mount('#app');

View File

@@ -0,0 +1,19 @@
import { auth } from '~/helpers/auth';
import { HoppModule } from '.';
const isAdmin = () => {
const user = auth.getCurrentUser();
return user ? user.isAdmin : false;
};
export default <HoppModule>{
onBeforeRouteChange(to, from, next) {
if (to.name !== 'index' && !isAdmin()) {
next({ name: 'index' });
} else if (to.name === 'index' && isAdmin()) {
next({ name: 'dashboard' });
} else {
next();
}
},
};

View File

@@ -0,0 +1,57 @@
import { App } from 'vue';
import { pipe } from 'fp-ts/function';
import * as A from 'fp-ts/Array';
import {
NavigationGuardNext,
RouteLocationNormalized,
Router,
} from 'vue-router';
export type HoppModule = {
/**
* Define this function to get access to Vue App instance and augment
* it (installing components, directives and plugins). Also useful for
* early generic initializations. This function should be called first
*/
onVueAppInit?: (app: App) => void;
/**
* Called when the router is done initializing.
* Used if a module requires access to the router instance
*/
onRouterInit?: (app: App, router: Router) => void;
/**
* Called when the root component (App.vue) is running setup.
* This function is generally called last in the lifecycle.
* This function executes with a component setup context, so you can
* run composables within this and it should just be scoped to the
* root component
*/
onRootSetup?: () => void;
/**
* Called by the router to tell all the modules before a route navigation
* is made.
*/
onBeforeRouteChange?: (
to: RouteLocationNormalized,
from: RouteLocationNormalized,
next: NavigationGuardNext,
router: Router
) => void;
/**
* Called by the router to tell all the modules that a route navigation has completed
*/
onAfterRouteChange?: (to: RouteLocationNormalized, router: Router) => void;
};
/**
* All the modules Hoppscotch loads into the app
*/
export const HOPP_MODULES = pipe(
import.meta.glob('@modules/*.ts', { eager: true }),
Object.values,
A.map(({ default: defaultVal }) => defaultVal as HoppModule)
);

View File

@@ -0,0 +1,72 @@
import { HoppModule, HOPP_MODULES } from '.';
import {
createRouter,
createWebHistory,
RouteLocationNormalized,
} from 'vue-router';
import { setupLayouts } from 'virtual:generated-layouts';
import generatedRoutes from 'virtual:generated-pages';
import { readonly, ref } from 'vue';
const routes = setupLayouts(generatedRoutes);
/**
* A reactive value signifying whether we are currently navigating
* into the first route the application is routing into.
* Useful, if you want to do stuff for the initial page load (for example splash screens!)
*/
const _isLoadingInitialRoute = ref(false);
/**
* Says whether a given route looks like an initial route which
* is loaded as the first route.
*
* NOTE: This function assumes Vue Router represents that initial route
* in the way we expect (fullPath == "/" and name == undefined). If this
* function breaks later on, most probs vue-router updated its semantics
* and we have to correct this function.
*/
function isInitialRoute(route: RouteLocationNormalized) {
return route.fullPath === '/' && route.name === undefined;
}
/**
* A reactive value signifying whether we are currently navigating
* into the first route the application is routing into.
* Useful, if you want to do stuff for the initial page load (for example splash screens!)
*
* NOTE: This reactive value is READONLY
*/
export const isLoadingInitialRoute = readonly(_isLoadingInitialRoute);
export default <HoppModule>{
onVueAppInit(app) {
const router = createRouter({
history: createWebHistory(),
routes,
});
router.beforeEach((to, from, next) => {
_isLoadingInitialRoute.value = isInitialRoute(from);
HOPP_MODULES.forEach((mod) => {
mod.onBeforeRouteChange?.(to, from, next, router);
});
});
// Instead of this a better architecture is for the router
// module to expose a stream of router events that can be independently
// subbed to
router.afterEach((to) => {
_isLoadingInitialRoute.value = false;
HOPP_MODULES.forEach((mod) => {
mod.onAfterRouteChange?.(to, router);
});
});
app.use(router);
HOPP_MODULES.forEach((mod) => mod.onRouterInit?.(app, router));
},
};

View File

@@ -0,0 +1,19 @@
import Toasted from '@hoppscotch/vue-toasted';
import type { ToastOptions } from '@hoppscotch/vue-toasted';
import { HoppModule } from '.';
import '@hoppscotch/vue-toasted/style.css';
// We are using a fork of Vue Toasted (github.com/clayzar/vue-toasted) which is a bit of
// an untrusted fork, we will either want to make our own fork or move to a more stable one
// The original Vue Toasted doesn't support Vue 3 and the OP has been irresponsive.
export default <HoppModule>{
onVueAppInit(app) {
app.use(Toasted, <ToastOptions>{
position: 'bottom-center',
duration: 3000,
keepOnHover: true,
});
},
};

View File

@@ -0,0 +1,13 @@
import { HoppModule } from '.';
import { plugin as HoppUI, HoppUIPluginOptions } from '@hoppscotch/ui';
const HoppUIOptions: HoppUIPluginOptions = {};
export default <HoppModule>{
onVueAppInit(app) {
// disable eslint for this line. it's a hack because there's some unknown type error
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
app.use(HoppUI, HoppUIOptions);
},
};

View File

@@ -0,0 +1,15 @@
import { nextTick } from "vue"
import { HoppModule } from "."
/*
Declares a `v-focus` directive that can be used for components
to acquire focus instantly once mounted
*/
export default <HoppModule>{
onVueAppInit(app) {
app.directive("focus", {
mounted: (el) => nextTick(() => el.focus()),
})
},
}

View File

@@ -1,113 +1,25 @@
<template>
<div
class="flex items-center justify-center h-screen px-6 bg-gray-200 dark:bg-gradient-to-r dark:from-zinc-700 dark:to-gray-900"
class="flex items-center justify-center h-screen bg-gray-200 dark:bg-gradient-to-r dark:from-zinc-700 dark:to-gray-900 p-6"
>
<div
class="w-full max-w-lg p-8 bg-white dark:bg-gradient-to-r dark:from-zinc-800 dark:to-gray-900 rounded-lg shadow-md"
>
<div class="flex items-center justify-center mt-6">
<div class="flex items-center">
<img src="/public/cover.jpg" alt="" class="h-12" />
<span
class="mx-2 text-2xl font-semibold text-gray-600 dark:text-gray-300"
>Hoppscotch</span
>
</div>
<div>
<div class="flex flex-col items-center justify-center mb-10">
<HoppButtonSecondary
class="tracking-wide !font-bold !text-secondaryDark hover:bg-primaryDark focus-visible:bg-primaryDark uppercase"
:label="'Hoppscotch'"
to="/"
/>
<span> Dashboard </span>
</div>
<div
class="bg-primary xs:w-xs sm:w-sm md:w-lg p-10 rounded-md border border-secondaryLight"
>
<AppLogin />
</div>
<form class="mt-4" @submit.prevent="login">
<label class="block">
<span class="text-sm text-gray-400">Email</span>
<input
type="email"
class="block w-full p-2 mt-1 bg-gray-700 border-gray-700 rounded-md focus:border-emerald-600 focus:ring focus:ring-opacity-40 focus:ring-emerald-500"
v-model="email"
/>
</label>
<label class="block mt-3">
<span class="text-sm text-gray-400">Password</span>
<input
type="password"
class="block w-full p-2 mt-1 bg-gray-700 border-gray-700 rounded-md focus:border-emerald-600 focus:ring focus:ring-opacity-40 focus:ring-emerald-500"
v-model="password"
/>
</label>
<div class="flex items-center justify-between mt-4">
<div>
<label class="inline-flex items-center">
<input
type="checkbox"
class="text-indigo-600 border-gray-200 rounded-md focus:border-emerald-600 focus:ring focus:ring-opacity-40 focus:ring-emerald-500"
/>
<span class="mx-2 text-sm text-gray-400">Remember me</span>
</label>
</div>
<div>
<a
class="block text-sm text-emerald-700 fontme hover:underline"
href="#"
>Forgot your password?</a
>
</div>
</div>
<div class="mt-6">
<button
type="submit"
class="w-full px-4 py-2 text-sm text-center text-white bg-emerald-600 rounded-md focus:outline-none hover:bg-emerald-500"
>
Sign in
</button>
</div>
<div class="mt-6">
<button
type="submit"
class="w-full px-4 py-2 text-sm text-center text-white border border-gray-500 rounded-md focus:outline-none hover:bg-gray-700"
>
Sign in with Google
</button>
</div>
<div class="mt-6">
<button
type="submit"
class="w-full px-4 py-2 text-sm text-center text-white border border-gray-500 rounded-md focus:outline-none hover:bg-gray-700"
>
Sign in with Microsoft
</button>
</div>
<div class="mt-6">
<button
type="submit"
class="w-full px-4 py-2 text-sm text-center text-white border border-gray-500 rounded-md focus:outline-none hover:bg-gray-700"
>
Sign in with Github
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
const email = ref('joel@gmail.com');
const password = ref('@#!@#asdf1231!_!@#');
function login() {
router.push('/dashboard');
}
</script>
<route lang="yaml">
meta:
layout: empty