setup basic auth

This commit is contained in:
Hayden 2022-08-30 16:07:21 -08:00
parent 5471cb16ff
commit 7361dcc5f7
14 changed files with 382 additions and 28 deletions

View file

@ -8,11 +8,14 @@ export {}
declare module '@vue/runtime-core' { declare module '@vue/runtime-core' {
export interface GlobalComponents { export interface GlobalComponents {
AppHeader: typeof import('./src/components/AppHeader.vue')['default'] AppHeader: typeof import('./src/components/AppHeader.vue')['default']
Icon: typeof import('./src/components/Icon.vue')['default']
'Icon:bx:bxMoon': typeof import('~icons/bx/bx-moon')['default'] 'Icon:bx:bxMoon': typeof import('~icons/bx/bx-moon')['default']
'Icon:bx:bxsMoon': typeof import('~icons/bx/bxs-moon')['default'] 'Icon:bx:bxsMoon': typeof import('~icons/bx/bxs-moon')['default']
'IconAkarIcons:githubFill': typeof import('~icons/akar-icons/github-fill')['default'] 'IconAkarIcons:githubFill': typeof import('~icons/akar-icons/github-fill')['default']
Notifier: typeof import('./src/components/App/Notifier.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
TextField: typeof import('./src/components/Form/TextField.vue')['default'] TextField: typeof import('./src/components/Form/TextField.vue')['default']
Toast: typeof import('./src/components/App/Toast.vue')['default']
} }
} }

View file

@ -23,6 +23,7 @@
"dev": "vite", "dev": "vite",
"build": "vite-ssg build", "build": "vite-ssg build",
"serve": "vite preview", "serve": "vite preview",
"test:watch": "vitest --watch",
"https-preview": "serve dist" "https-preview": "serve dist"
}, },
"dependencies": { "dependencies": {
@ -46,6 +47,7 @@
}, },
"devDependencies": { "devDependencies": {
"@iconify/json": "^2.1.78", "@iconify/json": "^2.1.78",
"@iconify/vue": "^3.2.1",
"@intlify/vite-plugin-vue-i18n": "^5.0.0", "@intlify/vite-plugin-vue-i18n": "^5.0.0",
"@vitejs/plugin-vue": "^3.0.0", "@vitejs/plugin-vue": "^3.0.0",
"@vue/compiler-sfc": "^3.2.37", "@vue/compiler-sfc": "^3.2.37",
@ -66,4 +68,4 @@
"vitest": "^0.18.0", "vitest": "^0.18.0",
"vue-tsc": "^0.38.5" "vue-tsc": "^0.38.5"
} }
} }

View file

@ -2,6 +2,7 @@ lockfileVersion: 5.4
specifiers: specifiers:
'@iconify/json': ^2.1.78 '@iconify/json': ^2.1.78
'@iconify/vue': ^3.2.1
'@intlify/vite-plugin-vue-i18n': ^5.0.0 '@intlify/vite-plugin-vue-i18n': ^5.0.0
'@tailwindcss/aspect-ratio': ^0.4.0 '@tailwindcss/aspect-ratio': ^0.4.0
'@tailwindcss/forms': ^0.5.2 '@tailwindcss/forms': ^0.5.2
@ -60,6 +61,7 @@ dependencies:
devDependencies: devDependencies:
'@iconify/json': 2.1.78 '@iconify/json': 2.1.78
'@iconify/vue': 3.2.1_vue@3.2.37
'@intlify/vite-plugin-vue-i18n': 5.0.0_vite@3.0.0+vue-i18n@9.1.10 '@intlify/vite-plugin-vue-i18n': 5.0.0_vite@3.0.0+vue-i18n@9.1.10
'@vitejs/plugin-vue': 3.0.0_vite@3.0.0+vue@3.2.37 '@vitejs/plugin-vue': 3.0.0_vite@3.0.0+vue@3.2.37
'@vue/compiler-sfc': 3.2.37 '@vue/compiler-sfc': 3.2.37
@ -1269,6 +1271,14 @@ packages:
- supports-color - supports-color
dev: true dev: true
/@iconify/vue/3.2.1_vue@3.2.37:
resolution: {integrity: sha512-c4R6ZgFo1JrJ8aPMMgOPgfU7lBswihMGR+yWe/P4ZukC3kTkeT4+lkt9Pc/itVFMkwva/S/7u9YofmYv57fnNQ==}
peerDependencies:
vue: 3.x
dependencies:
vue: 3.2.37
dev: true
/@intlify/bundle-utils/3.1.0_vue-i18n@9.1.10: /@intlify/bundle-utils/3.1.0_vue-i18n@9.1.10:
resolution: {integrity: sha512-ghlJ0kR2cCQ8D+poKknC0Xx0ncOt3J3os7CcIAqqIWVF7k6AtGoCDnIru+YzlZcvFRNmP9wEZ7jKliojCdAWNg==} resolution: {integrity: sha512-ghlJ0kR2cCQ8D+poKknC0Xx0ncOt3J3os7CcIAqqIWVF7k6AtGoCDnIru+YzlZcvFRNmP9wEZ7jKliojCdAWNg==}
engines: {node: '>= 12'} engines: {node: '>= 12'}

View file

@ -1,3 +1,8 @@
<script setup lang="ts">
import Toast from './components/App/Toast.vue';
</script>
<template> <template>
<Toast />
<router-view /> <router-view />
</template> </template>

View file

@ -0,0 +1,71 @@
<template>
<div class="force-above fixed top-2 right-2 w-[300px]">
<TransitionGroup name="notify" tag="div">
<div
v-for="(notify, index) in notifications.slice(0, 4)"
:key="notify.id"
class="my-2 w-[300px] rounded-md p-3 text-sm text-white opacity-75"
:class="{
'bg-primary': notify.type === 'info',
'bg-red-600': notify.type === 'error',
'bg-green-600': notify.type === 'success',
}"
@click="dropNotification(index)"
>
<div class="flex gap-1">
<template v-if="notify.type == 'info'">
<Icon
icon="mdi-information-outline"
class="h-5 w-5"
height="25"
/>
</template>
<template v-if="notify.type == 'success'">
<Icon
icon="mdi-check-circle-outline"
class="h-5 w-5"
height="25"
/>
</template>
<template v-if="notify.type == 'error'">
<Icon
icon="mdi-alert-circle-outline"
class="h-5 w-5"
height="25"
/>
</template>
{{ notify.message }}
</div>
</div>
</TransitionGroup>
</div>
</template>
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { useNotifications } from '@/composables/use-notifier';
const { notifications, dropNotification } = useNotifications();
</script>
<style scoped>
.force-above {
z-index: 9999;
}
.notify-move,
.notify-enter-active,
.notify-leave-active {
transition: all 0.5s ease;
}
.notify-enter-from,
.notify-leave-to {
opacity: 0;
transform: translateY(-30px);
}
.notify-leave-active {
position: absolute;
transform: translateY(30px);
}
</style>

View file

@ -0,0 +1,23 @@
import { PublicApi } from '@/api/public';
import { UserApi } from '@/api/user';
import { Requests } from '@/lib/requests';
import { useAuthStore } from '@/store/auth';
async function ApiDebugger(r: Response) {
console.table({
'Request Url': r.url,
'Response Status': r.status,
'Response Status Text': r.statusText,
});
}
export function usePublicApi(): PublicApi {
const requests = new Requests('', '', {}, ApiDebugger);
return new PublicApi(requests);
}
export function useUserApi(): UserApi {
const authStore = useAuthStore();
const requests = new Requests('', () => authStore.token, {}, ApiDebugger);
return new UserApi(requests);
}

View file

@ -0,0 +1,31 @@
function slugify(text: string) {
return text
.toString()
.toLowerCase()
.replace(/\s+/g, '-') // Replace spaces with -
.replace(/[^\w\-]+/g, '') // Remove all non-word chars
.replace(/\-\-+/g, '-') // Replace multiple - with single -
.replace(/^-+/, '') // Trim - from start of text
.replace(/-+$/, ''); // Trim - from end of text
}
function idGenerator(): string {
const id =
Math.random().toString(32).substring(2, 6) +
Math.random().toString(36).substring(2, 6);
return slugify(id);
}
/**
* useFormIds uses the provided label to generate a unique id for the
* form element. If no label is provided the id is generated using a
* random string.
*/
export function useFormIds(label: string): string {
const slug = label ? slugify(label) : idGenerator();
return `${slug}-${idGenerator()}`;
}
export function useId(): string {
return idGenerator();
}

View file

@ -0,0 +1,57 @@
import { useId } from './use-ids';
interface Notification {
id: string;
message: string;
type: 'success' | 'error' | 'info';
}
const notifications = ref<Notification[]>([]);
function addNotification(notification: Notification) {
notifications.value.unshift(notification);
if (notifications.value.length > 4) {
notifications.value.pop();
} else {
setTimeout(() => {
// Remove notification with ID
notifications.value = notifications.value.filter(
n => n.id !== notification.id
);
}, 5000);
}
}
export function useNotifications() {
return {
notifications,
dropNotification: (idx: number) => notifications.value.splice(idx, 1),
};
}
export function useNotifier() {
return {
success: (message: string) => {
addNotification({
id: useId(),
message,
type: 'success',
});
},
error: (message: string) => {
addNotification({
id: useId(),
message,
type: 'error',
});
},
info: (message: string) => {
addNotification({
id: useId(),
message,
type: 'info',
});
},
};
}

View file

@ -1,17 +1,14 @@
<script setup lang="ts"></script> <script setup lang="ts">
import Toast from '@/components/App/Toast.vue';
</script>
<template> <template>
<Toast />
<header> <header>
<app-header /> <app-header />
</header> </header>
<main <main
class=" class="p-8 dark:bg-gray-800 dark:text-white bg-white text-gray-800 min-h-screen"
p-8
dark:bg-gray-800 dark:text-white
bg-white
text-gray-800
min-h-screen
"
> >
<router-view /> <router-view />
</main> </main>

View file

@ -0,0 +1,59 @@
<script setup lang="ts">
import { useUserApi } from '@/composables/use-api';
useHead({
title: 'Homebox | Home',
});
const links = [
{
name: 'Home',
href: '/home',
},
{
name: 'Logout',
href: '/logout',
last: true,
},
];
const api = useUserApi();
const user = ref({});
onMounted(async () => {
const { data } = await api.self();
if (data) {
user.value = data.item;
}
});
</script>
<template>
<section class="max-w-7xl mx-auto">
<header class="sm:px-6 py-2 lg:p-14 sm:py-6">
<h2
class="mt-1 text-4xl font-bold tracking-tight text-gray-200 sm:text-5xl lg:text-6xl"
>
Homebox
</h2>
<div class="ml-1 text-lg text-gray-400 space-x-2 italic">
<template v-for="link in links">
<router-link
class="hover:text-base-content transition-color duration-200"
:to="link.href"
>
{{ link.name }}
</router-link>
<span v-if="!link.last"> / </span>
</template>
</div>
</header>
</section>
<section class="max-w-7xl mx-auto sm:px-6 lg:px-14">
{{ user }}
</section>
</template>
<route lang="yaml">
name: home
</route>

View file

@ -1,5 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import TextField from '@/components/Form/TextField.vue'; import TextField from '@/components/Form/TextField.vue';
import { useNotifier } from '@/composables/use-notifier';
import { usePublicApi } from '@/composables/use-api';
import { useAuthStore } from '@/store/auth';
useHead({ useHead({
title: 'Homebox | Organize and Tag Your Stuff', title: 'Homebox | Organize and Tag Your Stuff',
}); });
@ -29,12 +32,29 @@
}, },
]; ];
function registerUser() { const api = usePublicApi();
async function registerUser() {
loading.value = true;
// Print Values of registerFields // Print Values of registerFields
for (let i = 0; i < registerFields.length; i++) { const { data, error } = await api.register({
console.log(registerFields[i].label, registerFields[i].value); user: {
name: registerFields[0].value,
email: registerFields[1].value,
password: registerFields[3].value,
},
groupName: registerFields[2].value,
});
if (error) {
toast.error('Problem registering user');
} else {
toast.success('User registered');
} }
console.log(data);
loading.value = false;
} }
const loginFields = [ const loginFields = [
@ -49,8 +69,38 @@
}, },
]; ];
const registerForm = ref(false); const authStore = useAuthStore();
const toast = useNotifier();
const loading = ref(false);
const router = useRouter();
async function login() {
loading.value = true;
const { data, error } = await api.login(
loginFields[0].value,
loginFields[1].value
);
if (error) {
toast.error('Invalid email or password');
} else {
toast.success('Logged in successfully');
console.log(data);
authStore.$patch({
token: data.token,
expires: data.expiresAt,
});
router.push({ name: 'home' });
}
loading.value = false;
}
const registerForm = ref(false);
function toggleLogin() { function toggleLogin() {
registerForm.value = !registerForm.value; registerForm.value = !registerForm.value;
} }
@ -83,17 +133,19 @@
:type="field.type" :type="field.type"
/> />
<div class="card-actions justify-end"> <div class="card-actions justify-end">
<button type="submit" class="btn btn-primary mt-2"> <button
type="submit"
class="btn btn-primary mt-2"
:class="loading ? 'loading' : ''"
:disabled="loading"
>
Register Register
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<div class="text-center mt-2">
<button @click="toggleLogin">Already a User? Login</button>
</div>
</form> </form>
<div v-else> <form v-else @submit.prevent="login">
<div <div
class="card w-max-[500px] md:w-[500px] bg-base-100 shadow-xl" class="card w-max-[500px] md:w-[500px] bg-base-100 shadow-xl"
> >
@ -107,15 +159,28 @@
:type="field.type" :type="field.type"
/> />
<div class="card-actions justify-end mt-2"> <div class="card-actions justify-end mt-2">
<button class="btn btn-primary">Login</button> <button
type="submit"
class="btn btn-primary"
:class="loading ? 'loading' : ''"
:disabled="loading"
>
Login
</button>
</div> </div>
</div> </div>
</div> </div>
<div class="text-center mt-2"> </form>
<button @click="toggleLogin">Not a User? Register</button>
</div>
</div>
</Transition> </Transition>
<div class="text-center mt-2">
<button @click="toggleLogin">
{{
registerForm
? 'Already a User? Login'
: 'Not a User? Register'
}}
</button>
</div>
</div> </div>
<div class="min-w-full absolute bottom-0 z-[-1]"> <div class="min-w-full absolute bottom-0 z-[-1]">
<svg <svg
@ -140,10 +205,10 @@
</template> </template>
<route lang="yaml"> <route lang="yaml">
name: home name: login
</route> </route>
<style lang="css"> <style lang="css" scoped>
.slide-fade-enter-active { .slide-fade-enter-active {
transition: all 0.2s ease-out; transition: all 0.2s ease-out;
} }

View file

@ -0,0 +1,21 @@
import { defineStore } from 'pinia';
export const useAuthStore = defineStore('auth', {
state: () => ({
token: useLocalStorage('pinia/auth/token', ''),
expires: useLocalStorage('pinia/auth/expires', ''),
}),
getters: {
isTokenExpired: state => {
if (!state.expires) {
return true;
}
if (typeof state.expires === 'string') {
return new Date(state.expires) < new Date();
}
return state.expires < new Date();
},
},
});

View file

@ -29,8 +29,9 @@ import type {
declare module '@vue-router/routes' { declare module '@vue-router/routes' {
export interface RouteNamedMap { export interface RouteNamedMap {
'home': RouteRecordInfo<'home', '/', Record<never, never>, Record<never, never>>, 'login': RouteRecordInfo<'login', '/', Record<never, never>, Record<never, never>>,
'not-found': RouteRecordInfo<'not-found', '/:all(.*)', { all: ParamValue<true> }, { all: ParamValue<false> }>, 'not-found': RouteRecordInfo<'not-found', '/:all(.*)', { all: ParamValue<true> }, { all: ParamValue<false> }>,
'home': RouteRecordInfo<'home', '/home', Record<never, never>, Record<never, never>>,
} }
} }

View file

@ -102,6 +102,11 @@ export default defineConfig({
fs: { fs: {
strict: true, strict: true,
}, },
proxy: {
'/api': {
target: 'http://localhost:7745',
},
},
}, },
optimizeDeps: { optimizeDeps: {
include: ['vue', 'vue-router', '@vueuse/core', '@vueuse/head'], include: ['vue', 'vue-router', '@vueuse/core', '@vueuse/head'],
@ -114,11 +119,15 @@ export default defineConfig({
onFinished() { onFinished() {
generateSitemap(); generateSitemap();
}, },
mock: true mock: true,
}, },
// https://github.com/vitest-dev/vitest // https://github.com/vitest-dev/vitest
test: { test: {
include: ['src/__test__/**/*.test.ts', 'src/__test__/**/*.spec.ts'], include: [
'src/__test__/**/*.test.ts',
'src/**/*.test.ts',
'src/__test__/**/*.spec.ts',
],
environment: 'jsdom', environment: 'jsdom',
deps: { deps: {
inline: ['@vue', '@vueuse', 'vue-demi'], inline: ['@vue', '@vueuse', 'vue-demi'],