feat: introducing Auth for admin dashboard (HBE-138) (#32)
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
236
packages/hoppscotch-sh-admin/src/components/app/Login.vue
Normal file
236
packages/hoppscotch-sh-admin/src/components/app/Login.vue
Normal 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>
|
||||
62
packages/hoppscotch-sh-admin/src/components/app/Logout.vue
Normal file
62
packages/hoppscotch-sh-admin/src/components/app/Logout.vue
Normal 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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user