initial commit. Cloned timetracker repository
This commit is contained in:
15
bo/src/views/HomeView.vue
Normal file
15
bo/src/views/HomeView.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<main class="flex gap-4">
|
||||
<RouterLink class="bg-(--color-blue-600) dark:bg-(--color-blue-500) px-2 py-1 rounded text-white flex items-center shadow-md"
|
||||
:to="{ name: 'login' }">Login
|
||||
</RouterLink>
|
||||
<RouterLink class="bg-(--color-blue-600) dark:bg-(--color-blue-500) px-2 py-1 rounded text-white flex items-center shadow-md"
|
||||
:to="{ name: 'register' }">
|
||||
Register</RouterLink>
|
||||
<RouterLink class="bg-(--color-blue-600) dark:bg-(--color-blue-500) px-2 py-1 rounded text-white flex items-center shadow-md"
|
||||
:to="{ name: 'chart' }">Chart
|
||||
</RouterLink>
|
||||
</main>
|
||||
</template>
|
||||
159
bo/src/views/LoginView.vue
Normal file
159
bo/src/views/LoginView.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useValidation } from '@/composable/useValidation'
|
||||
import type { FormError } from '@nuxt/ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { i18n } from '@/plugins/i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const showPassword = ref(false)
|
||||
const validation = useValidation()
|
||||
|
||||
async function handleLogin() {
|
||||
const success = await authStore.login(email.value, password.value)
|
||||
if (success) {
|
||||
const redirectTo = route.query.redirect as string
|
||||
router.push(redirectTo || { name: 'chart' })
|
||||
}
|
||||
}
|
||||
|
||||
function goToRegister() {
|
||||
router.push({ name: 'register' })
|
||||
}
|
||||
|
||||
function goToPasswordRecovery() {
|
||||
router.push({ name: 'password-recovery' })
|
||||
}
|
||||
|
||||
function validate(): FormError[] {
|
||||
validation.reset()
|
||||
validation.validateEmail(email, 'email', i18n.t('validate_error.email_required'))
|
||||
if (!password.value) {
|
||||
validation.errors.push({ name: 'password', message: i18n.t('validate_error.password_required') })
|
||||
}
|
||||
return validation.errors
|
||||
}
|
||||
|
||||
const showTherms = ref(false)
|
||||
const showPrivacy = ref(false)
|
||||
const TermsComponent = computed(() =>
|
||||
defineAsyncComponent(() =>
|
||||
import(`@/components/terms/${i18n.locale.value}_TermsAndConditionsView.vue`).catch(() => import('@/components/terms/en_TermsAndConditionsView.vue')),
|
||||
),
|
||||
)
|
||||
const PrivacyComponent = computed(() =>
|
||||
defineAsyncComponent(() =>
|
||||
import(`@/components/terms/${i18n.locale.value}_PrivacyPolicyView.vue`).catch(() => import('@/components/terms/en_PrivacyPolicyView.vue')),
|
||||
),
|
||||
|
||||
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDrawer v-model:open="showTherms" :overlay="false">
|
||||
<template #body>
|
||||
<component :is="TermsComponent" />
|
||||
</template>
|
||||
<template #footer>
|
||||
<UButton @click="showTherms = false" class="mx-auto px-12">close</UButton>
|
||||
</template>
|
||||
</UDrawer>
|
||||
<!-- PrivacyPolicyView -->
|
||||
<UDrawer v-model:open="showPrivacy" :overlay="false">
|
||||
<template #body>
|
||||
<component :is="PrivacyComponent" />
|
||||
</template>
|
||||
<template #footer>
|
||||
<UButton @click="showPrivacy = false" class="mx-auto px-12">close</UButton>
|
||||
</template>
|
||||
</UDrawer>
|
||||
<div class="h-[100vh] flex items-center justify-center px-4 sm:px-6 lg:px-8">
|
||||
<div class="w-full max-w-md flex flex-col gap-4">
|
||||
<UForm :validate="validate" @submit="handleLogin" class="space-y-5">
|
||||
<UAlert v-if="authStore.error" color="error" variant="subtle" :title="authStore.error"
|
||||
:close-button="{ icon: 'i-heroicons-x-mark-20-solid', color: 'gray', variant: 'link' }"
|
||||
@close="authStore.clearError" />
|
||||
|
||||
<UFormField :label="$t('general.email_address')" name="email" required
|
||||
class="w-full dark:text-white text-black">
|
||||
<UInput v-model="email" :placeholder="$t('general.enter_your_email')" :disabled="authStore.loading"
|
||||
class="w-full dark:text-white text-black" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField :label="$t('general.password')" name="password" required class="w-full dark:text-white text-black">
|
||||
<UInput v-model="password" :placeholder="$t('general.enter_your_password')"
|
||||
:type="showPassword ? 'text' : 'password'" class="w-full" :ui="{ trailing: 'pe-1' }">
|
||||
<template #trailing>
|
||||
<UIcon color="neutral" variant="link" size="sm"
|
||||
:name="showPassword ? 'i-lucide-eye-off' : 'i-lucide-eye'"
|
||||
:aria-label="showPassword ? 'Hide password' : 'Show password'" :aria-pressed="showPassword"
|
||||
aria-controls="password" @click="showPassword = !showPassword" />
|
||||
</template>
|
||||
</UInput>
|
||||
</UFormField>
|
||||
|
||||
<div class="flex items-center justify-between w-full dark:text-white text-black">
|
||||
<button variant="link" size="sm" @click="goToPasswordRecovery" class="text-[15px] w-full flex justify-end text-(--color-blue-600) dark:text-(--color-blue-500)">
|
||||
{{$t('general.forgot_password')}}?
|
||||
</button>
|
||||
</div>
|
||||
<UButton type="submit" :loading="authStore.loading"
|
||||
class="w-full flex justify-center text-white bg-(--color-blue-600) dark:bg-(--color-blue-500)">
|
||||
{{ $t('general.sign_in') }}
|
||||
</UButton>
|
||||
</UForm>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="flex items-center gap-3 my-1">
|
||||
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-700" />
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">or</span>
|
||||
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-700" />
|
||||
</div>
|
||||
|
||||
<!-- Google Sign In -->
|
||||
<UButton type="button" color="neutral" variant="outline" size="lg" block :disabled="authStore.loading"
|
||||
@click="authStore.loginWithGoogle()" class="flex items-center justify-center gap-2 dark:text-white text-black">
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
fill="#4285F4" />
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
fill="#34A853" />
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"
|
||||
fill="#FBBC05" />
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#EA4335" />
|
||||
</svg>
|
||||
{{ $t('general.continue_with_google') }}
|
||||
</UButton>
|
||||
|
||||
<div class="text-center">
|
||||
<p class="dark:text-white text-black">
|
||||
{{$t('general.dont_have_an_account')}}?
|
||||
<button variant="link" size="sm" class="text-[15px] text-(--color-blue-600) dark:text-(--color-blue-500)" @click="goToRegister">{{ $t('general.create_account_now') }}</button>
|
||||
</p>
|
||||
</div>
|
||||
<p class="mt-8 text-center text-xs dark:text-white text-black">
|
||||
{{ $t('general.by_signing_in_you_agree_to_our') }}
|
||||
<span @click="showTherms = !showTherms"
|
||||
class="cursor-pointer underline text-(--color-blue-600) dark:text-(--color-blue-500)">{{
|
||||
$t('general.terms_of_service') }}</span>
|
||||
{{ $t('general.and') }}
|
||||
<span @click="showPrivacy = !showPrivacy"
|
||||
class="cursor-pointer underline text-(--color-blue-600) dark:text-(--color-blue-500)">{{
|
||||
$t('general.privacy_policy') }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
101
bo/src/views/PasswordRecoveryView.vue
Normal file
101
bo/src/views/PasswordRecoveryView.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useValidation } from '@/composable/useValidation'
|
||||
import type { FormError } from '@nuxt/ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { i18n } from '@/plugins/i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const validation = useValidation()
|
||||
|
||||
const email = ref('')
|
||||
const submitted = ref(false)
|
||||
|
||||
async function handleRecover() {
|
||||
const success = await authStore.requestPasswordReset(email.value)
|
||||
if (success) {
|
||||
submitted.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function goToLogin() {
|
||||
router.push({ name: 'login' })
|
||||
}
|
||||
|
||||
function goToRegister() {
|
||||
router.push({ name: 'register' })
|
||||
}
|
||||
|
||||
|
||||
function validate(): FormError[] {
|
||||
validation.reset()
|
||||
validation.validateEmail(email, 'email', i18n.t('validate_error.email_required'))
|
||||
return validation.errors
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-[100vh] flex items-center justify-center px-4 sm:px-6 lg:px-8">
|
||||
<div class="w-full max-w-md flex flex-col gap-4">
|
||||
|
||||
<!-- Success State -->
|
||||
<template v-if="submitted">
|
||||
<div class="text-center flex flex-col gap-4">
|
||||
<UIcon name="i-heroicons-envelope" class="w-12 h-12 mx-auto text-primary-500" />
|
||||
<h2 class="text-xl font-semibold dark:text-white text-black">{{ $t('general.check_your_email') }}</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ $t('general.password_reset_link_sent_notice') }}
|
||||
</p>
|
||||
<UButton color="neutral" variant="outline" block @click="goToLogin" class="dark:text-white text-black">
|
||||
{{ $t('general.back_to_sign_in') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Form State -->
|
||||
<template v-else>
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ $t('general.enter_email_for_password_reset') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UForm :validate="validate" @submit="handleRecover" class="flex flex-col gap-3">
|
||||
<UAlert v-if="authStore.error" color="error" variant="subtle" icon="i-heroicons-exclamation-triangle"
|
||||
:title="authStore.error" :close-button="{ icon: 'i-heroicons-x-mark-20-solid', variant: 'link' }"
|
||||
@close="authStore.clearError" />
|
||||
|
||||
<UFormField :label="$t('general.email_address')" name="email" required
|
||||
class="w-full dark:text-white text-black">
|
||||
<UInput v-model="email" :placeholder="$t('general.enter_your_email')" :disabled="authStore.loading"
|
||||
class="w-full dark:text-white text-black" />
|
||||
</UFormField>
|
||||
|
||||
<UButton type="submit" block :loading="authStore.loading"
|
||||
class="text-white bg-(--color-blue-600) dark:bg-(--color-blue-500)">
|
||||
{{ $t('general.send_password_reset_link') }}
|
||||
</UButton>
|
||||
</UForm>
|
||||
|
||||
<div class="text-center flex flex-col gap-3 border-t dark:border-(--border-dark) border-(--border-light) pt-4">
|
||||
<UButton color="neutral" variant="outline" :loading="authStore.loading"
|
||||
class="w-full flex justify-center dark:text-white text-black" @click="goToLogin">
|
||||
{{ $t('general.back_to_sign_in') }}
|
||||
</UButton>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ $t('general.dont_have_an_account') }}
|
||||
<UButton variant="link" size="sm" @click="goToRegister"
|
||||
class="text-(--color-blue-600) dark:text-(--color-blue-500)">{{ $t('general.create_account_now') }}
|
||||
</UButton>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
155
bo/src/views/RegisterView.vue
Normal file
155
bo/src/views/RegisterView.vue
Normal file
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<UDrawer v-model:open="showTherms" :overlay="false">
|
||||
<template #body>
|
||||
<component :is="TermsComponent" />
|
||||
</template>
|
||||
<template #footer>
|
||||
<UButton @click="showTherms = false" class="mx-auto px-12">close</UButton>
|
||||
</template>
|
||||
</UDrawer>
|
||||
<!-- PrivacyPolicyView -->
|
||||
<UDrawer v-model:open="showPrivacy" :overlay="false">
|
||||
<template #body>
|
||||
<component :is="PrivacyComponent" />
|
||||
</template>
|
||||
<template #footer>
|
||||
<UButton @click="showPrivacy = false" class="mx-auto px-12">close</UButton>
|
||||
</template>
|
||||
</UDrawer>
|
||||
|
||||
<div class="h-[100vh] flex items-center justify-center px-4 sm:px-6 lg:px-8">
|
||||
<div class="w-full max-w-md">
|
||||
<UForm :validate="validate" @submit="handleRegister" class="flex flex-col gap-3">
|
||||
<UAlert v-if="authStore.error" color="error" variant="subtle" icon="i-heroicons-exclamation-triangle"
|
||||
:title="authStore.error" :close-button="{ icon: 'i-heroicons-x-mark-20-solid', variant: 'link' }"
|
||||
@close="authStore.clearError" />
|
||||
|
||||
<UFormField :label="$t('general.first_name')" name="first_name" required class="w-full dark:text-white text-black ">
|
||||
<UInput class="w-full" v-model="first_name" type="text" :placeholder="$t('general.first_name')"
|
||||
:disabled="authStore.loading">
|
||||
</UInput>
|
||||
</UFormField>
|
||||
|
||||
<UFormField :label="$t('general.last_name')" name="last_name" required class="w-full dark:text-white text-black">
|
||||
<UInput class="w-full dark:text-white text-black" v-model="last_name" type="text" :placeholder="$t('general.last_name')"
|
||||
:disabled="authStore.loading">
|
||||
</UInput>
|
||||
</UFormField>
|
||||
|
||||
<UFormField :label="$t('general.email_address')" name="email" required class="w-full dark:text-white text-black">
|
||||
<UInput v-model="email" :placeholder="$t('general.enter_your_email')" :disabled="authStore.loading"
|
||||
class="w-full dark:text-white text-black" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField :label="$t('general.password')" name="password" required class="w-full dark:text-white text-black">
|
||||
|
||||
<UInput v-model="password" :placeholder="$t('general.enter_your_password')" :disabled="authStore.loading"
|
||||
class="w-full dark:text-white text-black" :type="showPassword ? 'text' : 'password'" :ui="{ trailing: 'pe-1' }">
|
||||
<template #trailing>
|
||||
<UIcon color="neutral" variant="link" size="sm"
|
||||
:name="showPassword ? 'i-lucide-eye-off' : 'i-lucide-eye'"
|
||||
:aria-label="showPassword ? 'Hide password' : 'Show password'" :aria-pressed="showPassword"
|
||||
aria-controls="password" @click="showPassword = !showPassword" />
|
||||
</template>
|
||||
</UInput>
|
||||
</UFormField>
|
||||
|
||||
<UFormField :label="$t('general.confirm_password')" name="confirm_password" required class="w-full dark:text-white text-black">
|
||||
<UInput v-model="confirm_password_ref" :type="showConfirmPassword ? 'text' : 'password'" class="w-full dark:text-white text-black"
|
||||
:placeholder="$t('general.confirm_your_password')" :disabled="authStore.loading" :ui="{ trailing: 'pe-1' }">
|
||||
<template #trailing>
|
||||
<UIcon color="neutral" variant="ghost" size="sm"
|
||||
:name="showConfirmPassword ? 'i-lucide-eye-off' : 'i-lucide-eye'"
|
||||
@click="showConfirmPassword = !showConfirmPassword" />
|
||||
</template>
|
||||
</UInput>
|
||||
</UFormField>
|
||||
|
||||
<UCheckbox v-model="acceptTerms" class="label mb-3">
|
||||
<template #label>
|
||||
<span class="dark:text-white text-black">
|
||||
{{ $t('general.i_agree_to_the') }}
|
||||
<span @click="showTherms = !showTherms" class="cursor-pointer underline text-(--color-blue-600) dark:text-(--color-blue-500)">{{ $t('general.terms_of_service')
|
||||
}}</span>
|
||||
{{ $t('general.and') }}
|
||||
<span @click="showPrivacy = !showPrivacy" class="cursor-pointer underline text-(--color-blue-600) dark:text-(--color-blue-500)">{{ $t('general.privacy_policy')
|
||||
}}</span>
|
||||
</span>
|
||||
</template>
|
||||
</UCheckbox>
|
||||
|
||||
<UButton type="submit" block :loading="authStore.loading" :disabled="!acceptTerms" class="text-white bg-(--color-blue-600) dark:bg-(--color-blue-500)">
|
||||
{{ $t('general.create_account') }}
|
||||
</UButton>
|
||||
|
||||
<div class="text-center flex flex-col gap-3 border-t dark:border-(--border-dark) border-(--border-light) pt-4">
|
||||
<p class="dark:text-white text-black">
|
||||
{{ $t('general.already_have_an_account') }}
|
||||
</p>
|
||||
<UButton color="neutral" variant="outline" :loading="authStore.loading" class="w-full flex justify-center dark:text-white hover:text-white hover:bg-(--color-blue-600) dark:hover:bg-(--color-blue-500) border border-(--border-light)! dark:border-(--border-dark)!"
|
||||
@click="goToLogin">{{ $t('general.sign_in') }}</UButton>
|
||||
</div>
|
||||
</UForm>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, defineAsyncComponent } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useValidation } from '@/composable/useValidation'
|
||||
import type { FormError } from '@nuxt/ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { i18n } from '@/plugins/i18n'
|
||||
|
||||
const { locale } = useI18n()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const acceptTerms = ref(false)
|
||||
const showConfirmPassword = ref(false)
|
||||
const showPassword = ref(false)
|
||||
const validation = useValidation()
|
||||
|
||||
const first_name = ref('')
|
||||
const last_name = ref('')
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const confirm_password_ref = ref('')
|
||||
|
||||
async function handleRegister() {
|
||||
const result = await authStore.register(first_name.value, last_name.value, email.value, password.value, confirm_password_ref.value, locale.value)
|
||||
if (result?.success) {
|
||||
router.push({ name: 'login', query: { registered: 'true' } })
|
||||
}
|
||||
}
|
||||
|
||||
function goToLogin() {
|
||||
router.push({ name: 'login' })
|
||||
}
|
||||
|
||||
function validate(): FormError[] {
|
||||
validation.reset()
|
||||
validation.validateFirstName(first_name, 'first_name', i18n.t('validate_error.first_name_required'))
|
||||
validation.validateLastName(last_name, 'last_name', i18n.t('validate_error.last_name_required'))
|
||||
validation.validateEmail(email, 'email', i18n.t('validate_error.email_required'))
|
||||
validation.validatePasswords(password, 'password', confirm_password_ref, 'confirm_password', i18n.t('validate_error.confirm_password_required'))
|
||||
|
||||
return validation.errors
|
||||
}
|
||||
|
||||
const showTherms = ref(false)
|
||||
const showPrivacy = ref(false)
|
||||
const TermsComponent = computed(() =>
|
||||
defineAsyncComponent(() =>
|
||||
import(`@/components/terms/${i18n.locale.value}_TermsAndConditionsView.vue`).catch(() => import('@/components/terms/en_TermsAndConditionsView.vue')),
|
||||
),
|
||||
)
|
||||
const PrivacyComponent = computed(() =>
|
||||
defineAsyncComponent(() =>
|
||||
import(`@/components/terms/${i18n.locale.value}_PrivacyPolicyView.vue`).catch(() => import('@/components/terms/en_PrivacyPolicyView.vue')),
|
||||
),
|
||||
|
||||
|
||||
)
|
||||
</script>
|
||||
250
bo/src/views/RepoChartView.vue
Normal file
250
bo/src/views/RepoChartView.vue
Normal file
@@ -0,0 +1,250 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, type Ref } from 'vue'
|
||||
import { Bar } from 'vue-chartjs'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
} from 'chart.js'
|
||||
import { getRepos, getYears, getQuarters, getIssues, type QuarterData, type IssueTimeSummary } from '@/composable/useRepoApi'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { i18n } from '@/plugins/i18n'
|
||||
|
||||
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const repos = ref<number[]>([])
|
||||
const years = ref<number[]>([])
|
||||
const quarters = ref<QuarterData[]>([])
|
||||
const issues = ref<IssueTimeSummary[]>([])
|
||||
|
||||
const selectedRepo = ref<number | null>(null)
|
||||
const selectedYear = ref<number | null>(null)
|
||||
const selectedQuarter = ref<string | null>(null)
|
||||
|
||||
const page = ref(1)
|
||||
const totalItems = ref(0)
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function loadData<T>(fetchFn: () => Promise<any>, target: Ref<T[]>, errorMsg: string) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await fetchFn()
|
||||
target.value = response.items || response || []
|
||||
} catch (e: any) {
|
||||
error.value = e?.message || errorMsg
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => loadData(() => getRepos(), repos, i18n.t('repo_chart.failed_to_load_repositories')))
|
||||
|
||||
watch(selectedRepo, async (newRepo) => {
|
||||
selectedYear.value = null
|
||||
selectedQuarter.value = null
|
||||
quarters.value = []
|
||||
issues.value = []
|
||||
if (newRepo) {
|
||||
await loadData(() => getYears(newRepo), years, i18n.t('repo_chart.failed_to_load_years'))
|
||||
}
|
||||
})
|
||||
|
||||
watch(selectedYear, async (newYear) => {
|
||||
selectedQuarter.value = null
|
||||
issues.value = []
|
||||
if (newYear && selectedRepo.value) {
|
||||
await loadData(() => getQuarters(selectedRepo.value!, newYear), quarters, i18n.t('repo_chart.failed_to_load_quarters'))
|
||||
}
|
||||
})
|
||||
|
||||
watch(selectedQuarter, async (newQuarter) => {
|
||||
if (newQuarter && selectedRepo.value && selectedYear.value) {
|
||||
await loadIssues(selectedRepo.value, selectedYear.value, newQuarter)
|
||||
} else {
|
||||
issues.value = []
|
||||
}
|
||||
})
|
||||
|
||||
watch(page, () => {
|
||||
if (selectedRepo.value && selectedYear.value && selectedQuarter.value) {
|
||||
loadIssues(selectedRepo.value, selectedYear.value, selectedQuarter.value)
|
||||
}
|
||||
})
|
||||
|
||||
async function loadIssues(repoID: number, year: number, quarterStr: string) {
|
||||
const quarterPart = quarterStr.split('_Q')[1]
|
||||
if (!quarterPart) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await getIssues(repoID, year, parseInt(quarterPart), page.value, 50)
|
||||
issues.value = response.items || []
|
||||
totalItems.value = response.items_count || 0
|
||||
} catch (e: any) {
|
||||
error.value = e?.message || i18n.t('repo_chart.failed_to_load_issues')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const chartData = computed(() => ({
|
||||
labels: quarters.value.map((q) => q.quarter),
|
||||
datasets: [
|
||||
{
|
||||
label: i18n.t('repo_chart.hours_worked'),
|
||||
backgroundColor: '#3b82f6',
|
||||
data: quarters.value.map((q) => q.time),
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'top' as const },
|
||||
title: { display: true, text: i18n.t('repo_chart.work_by_quarter') },
|
||||
},
|
||||
scales: {
|
||||
y: { beginAtZero: true, title: { display: true, text: i18n.t('repo_chart.hours') } },
|
||||
},
|
||||
}
|
||||
|
||||
const hasData = computed(() => quarters.value.length > 0)
|
||||
const hasIssues = computed(() => issues.value.length > 0)
|
||||
|
||||
const items = computed(() => repos.value.map(r => ({ value: r, label: `Repo ${r}` })))
|
||||
|
||||
const yearItems = computed(() => [
|
||||
{ value: null, label: i18n.t('repo_chart.select_a_year') },
|
||||
...years.value.map(y => ({ value: y, label: String(y) }))
|
||||
])
|
||||
|
||||
const quarterItems = computed(() => [
|
||||
{ value: null, label: i18n.t('repo_chart.all_quarters') },
|
||||
...quarters.value.map(q => ({
|
||||
value: q.quarter,
|
||||
label: `${q.quarter} (${q.time.toFixed(1)}h)`
|
||||
}))
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container">
|
||||
<div class="p-6 bg-white dark:bg-(--black) min-h-screen font-sans">
|
||||
<h1 class="text-2xl font-bold mb-6 text-black">{{ $t('repo_chart.repository_work_chart') }}</h1>
|
||||
|
||||
<div v-if="error" class="mb-4 p-3 bg-red-100 text-red-700 rounded">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="mb-4 p-3 bg-blue-100 text-blue-700 rounded">
|
||||
{{ $t('repo_chart.loading') }}...
|
||||
</div>
|
||||
|
||||
<div v-if="!authStore.isAuthenticated" class="mb-4 p-3 bg-yellow-100 text-yellow-700 rounded">
|
||||
<!-- Please log in to view repository work charts. -->
|
||||
{{ $t('repo_chart.login_to_view_charts') }}
|
||||
</div>
|
||||
|
||||
<div v-if="authStore.isAuthenticated" class="flex flex-wrap gap-4 mb-6">
|
||||
<div class="flex flex-col min-w-[192px]">
|
||||
<label class="mb-1 text-sm font-medium text-black dark:text-white">{{ $t('repo_chart.repository')
|
||||
}}</label>
|
||||
<USelect v-model="selectedRepo" :items="items" :disabled="loading"
|
||||
:placeholder="$t('repo_chart.select_a_repository')" class="dark:text-white text-black "/>
|
||||
<!-- Select a repository -->
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col min-w-[160px]">
|
||||
<label class="mb-1 text-sm font-medium text-black dark:text-white">{{ $t('repo_chart.year')
|
||||
}}</label>
|
||||
<USelect v-model="selectedYear" :items="yearItems"
|
||||
:disabled="loading || !selectedRepo || years.length === 0"
|
||||
:placeholder="$t('repo_chart.select_a_year')" class="dark:text-white text-black "/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col min-w-[192px]">
|
||||
<label class="mb-1 text-sm font-medium text-black dark:text-white">{{ $t('repo_chart.quarter')
|
||||
}}</label>
|
||||
<USelect v-model="selectedQuarter" :items="quarterItems"
|
||||
:disabled="loading || !selectedYear || quarters.length === 0"
|
||||
:placeholder="$t('repo_chart.all_quarters')" class="dark:text-white text-black "/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasData && authStore.isAuthenticated"
|
||||
class="mb-6 p-4 border border-(--border-light) dark:border-(--border-dark) rounded">
|
||||
<h2 class="text-xl font-medium mb-4 text-black dark:text-white">{{
|
||||
$t('repo_chart.work_done_by_quarter') }}</h2>
|
||||
<div class="h-80">
|
||||
<Bar :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasIssues && authStore.isAuthenticated"
|
||||
class="p-4 border border-(--border-light) dark:border-(--border-dark) rounded">
|
||||
<h2 class="text-xl font-medium mb-4 text-black dark:text-white">{{ $t('repo_chart.issues_for') }} {{
|
||||
selectedQuarter }}
|
||||
</h2>
|
||||
|
||||
<table class="w-full border border-(--border-light) dark:border-(--border-dark)">
|
||||
<thead>
|
||||
<tr class="bg-gray-100 dark:bg-(--black)">
|
||||
<th
|
||||
class="p-3 text-left text-xs font-bold text-gray-600 dark:text-white uppercase border border-(--border-light) dark:border-(--border-dark)">
|
||||
ID</th>
|
||||
<th
|
||||
class="p-3 text-left text-xs font-bold text-gray-600 dark:text-white uppercase border border-(--border-light) dark:border-(--border-dark)">
|
||||
{{ $t('repo_chart.issue_name') }}</th>
|
||||
<th
|
||||
class="p-3 text-left text-xs font-bold text-gray-600 dark:text-white uppercase border border-(--border-light) dark:border-(--border-dark)">
|
||||
{{ $t('repo_chart.user_initials') }}</th>
|
||||
<th
|
||||
class="p-3 text-left text-xs font-bold text-gray-600 dark:text-white uppercase border border-(--border-light) dark:border-(--border-dark)">
|
||||
{{ $t('repo_chart.created_on') }}</th>
|
||||
<th
|
||||
class="p-3 text-left text-xs font-bold text-gray-600 dark:text-white uppercase border border-(--border-light) dark:border-(--border-dark)">
|
||||
{{ $t('repo_chart.hours_spent') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="issue in issues" :key="issue.IssueID"
|
||||
class=" border-b border-(--border-light) dark:border-(--border-dark)">
|
||||
<td class="p-3 text-black dark:text-white">{{ issue.IssueID }}</td>
|
||||
<td class="p-3 text-black dark:text-white">{{ issue.IssueName }}</td>
|
||||
<td class="p-3 text-black dark:text-white">{{ issue.Initials }}</td>
|
||||
<td class="p-3 text-black dark:text-white">{{ issue.CreatedDate }}</td>
|
||||
<td class="p-3 text-black dark:text-white">{{ issue.TotalHoursSpent }}h</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="pt-4 flex justify-center items-center dark:text-white!">
|
||||
<UPagination v-model:page="page" :total="totalItems" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="selectedQuarter && !loading && authStore.isAuthenticated && !hasIssues"
|
||||
class="mt-4 p-3 dark:bg-(--black) bg-white border border-(--border-light) dark:border-(--border-dark) dark:text-white text-black rounded">
|
||||
{{ $t('validate_error.no_issues_for_quarter') }}.
|
||||
</div>
|
||||
|
||||
<div v-else-if="!loading && authStore.isAuthenticated" class="p-3 dark:bg-(--black) bg-white border border-(--border-light) dark:border-(--border-dark) rounded">
|
||||
<span v-if="!selectedRepo">{{ $t('repo_chart.select_repo_to_view_data') }}</span>
|
||||
<span v-else-if="!selectedYear">{{ $t('repo_chart.select_year_to_view_data') }}</span>
|
||||
<span v-else-if="!selectedQuarter">{{ $t('repo_chart.select_quarter_to_view_issues') }}</span>
|
||||
<span v-else-if="quarters.length === 0">{{ $t('repo_chart.no_work_data_available') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
123
bo/src/views/ResetPasswordForm.vue
Normal file
123
bo/src/views/ResetPasswordForm.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useValidation } from '@/composable/useValidation'
|
||||
import type { FormError } from '@nuxt/ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { i18n } from '@/plugins/i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const validation = useValidation()
|
||||
|
||||
const new_password = ref('')
|
||||
const confirm_new_password = ref('')
|
||||
const showNewPassword = ref(false)
|
||||
const showConfirmPassword = ref(false)
|
||||
const resetToken = ref('')
|
||||
const submitted = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
resetToken.value = (route.query.token as string) || ''
|
||||
if (!resetToken.value) {
|
||||
router.push({ name: 'password-recovery' })
|
||||
}
|
||||
})
|
||||
|
||||
async function handleReset() {
|
||||
const success = await authStore.resetPassword(resetToken.value, new_password.value)
|
||||
if (success) {
|
||||
submitted.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function goToLogin() {
|
||||
router.push({ name: 'login' })
|
||||
}
|
||||
|
||||
function validate(): FormError[] {
|
||||
validation.reset()
|
||||
validation.validatePasswords(
|
||||
new_password,
|
||||
'new_password',
|
||||
confirm_new_password,
|
||||
'confirm_new_password',
|
||||
i18n.t('validate_error.confirm_password_required'),
|
||||
)
|
||||
return validation.errors
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-[100vh] flex items-center justify-center px-4 sm:px-6 lg:px-8">
|
||||
<div class="w-full max-w-md flex flex-col gap-4">
|
||||
|
||||
<!-- Success State -->
|
||||
<template v-if="submitted">
|
||||
<div class="text-center flex flex-col gap-4">
|
||||
<UIcon name="i-heroicons-check-circle" class="w-12 h-12 mx-auto text-green-500" />
|
||||
<h2 class="text-xl font-semibold dark:text-white text-black">
|
||||
{{ $t('general.password_updated') }}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ $t('general.password_updated_description') }}
|
||||
</p>
|
||||
<UButton block @click="goToLogin" class="dark:text-white text-black">
|
||||
{{ $t('general.back_to_sign_in') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Form State -->
|
||||
<template v-else>
|
||||
<UForm :validate="validate" @submit="handleReset" class="flex flex-col gap-3">
|
||||
<UAlert v-if="authStore.error" color="error" variant="subtle"
|
||||
icon="i-heroicons-exclamation-triangle" :title="authStore.error"
|
||||
:close-button="{ icon: 'i-heroicons-x-mark-20-solid', variant: 'link' }"
|
||||
@close="authStore.clearError" />
|
||||
|
||||
<UFormField :label="$t('general.new_password')" name="new_password" required class="w-full dark:text-white text-black">
|
||||
<UInput v-model="new_password" :type="showNewPassword ? 'text' : 'password'"
|
||||
:placeholder="$t('general.enter_your_new_password')" :disabled="authStore.loading"
|
||||
class="w-full dark:text-white text-black" :ui="{ trailing: 'pe-1' }">
|
||||
<template #trailing>
|
||||
<UIcon color="neutral" variant="link" size="sm"
|
||||
:name="showNewPassword ? 'i-lucide-eye-off' : 'i-lucide-eye'"
|
||||
:aria-label="showNewPassword ? 'Hide password' : 'Show password'"
|
||||
:aria-pressed="showNewPassword" aria-controls="new_password"
|
||||
@click="showNewPassword = !showNewPassword" />
|
||||
</template>
|
||||
</UInput>
|
||||
</UFormField>
|
||||
|
||||
<UFormField :label="$t('general.confirm_password')" name="confirm_new_password" required
|
||||
class="w-full dark:text-white text-black">
|
||||
<UInput v-model="confirm_new_password" :type="showConfirmPassword ? 'text' : 'password'"
|
||||
:placeholder="$t('general.confirm_your_new_password')" :disabled="authStore.loading"
|
||||
class="w-full dark:text-white text-black" :ui="{ trailing: 'pe-1' }">
|
||||
<template #trailing>
|
||||
<UIcon color="neutral" variant="ghost" size="sm"
|
||||
:name="showConfirmPassword ? 'i-lucide-eye-off' : 'i-lucide-eye'"
|
||||
@click="showConfirmPassword = !showConfirmPassword" />
|
||||
</template>
|
||||
</UInput>
|
||||
</UFormField>
|
||||
|
||||
<UButton type="submit" block :loading="authStore.loading" class="text-white! bg-(--color-blue-600) dark:bg-(--color-blue-500)">
|
||||
{{ $t('general.reset_password') }}
|
||||
</UButton>
|
||||
|
||||
<div class="text-center border-t dark:border-(--border-dark) border-(--border-light) pt-4">
|
||||
<UButton color="neutral" variant="ghost" @click="goToLogin" class="dark:text-white text-black">
|
||||
{{ $t('general.back_to_sign_in') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</UForm>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
159
bo/src/views/VerifyEmailView.vue
Normal file
159
bo/src/views/VerifyEmailView.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFetchJson } from '@/composable/useFetchJson'
|
||||
|
||||
const { t, te } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
// Helper function to get translation with fallback
|
||||
function tt(key: string, fallback: string): string {
|
||||
return te(key) ? t(key) : fallback
|
||||
}
|
||||
|
||||
const token = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const success = ref(false)
|
||||
const verificationInProgress = ref(true)
|
||||
|
||||
onMounted(() => {
|
||||
// Get token from URL query params
|
||||
token.value = (route.query.token as string) || ''
|
||||
|
||||
if (!token.value) {
|
||||
error.value = tt('verify_email.invalid_token', 'Invalid or missing verification token')
|
||||
verificationInProgress.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// Automatically verify email on page load
|
||||
handleVerifyEmail()
|
||||
})
|
||||
|
||||
async function handleVerifyEmail() {
|
||||
if (!token.value) {
|
||||
error.value = tt('verify_email.invalid_token', 'Invalid or missing verification token')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
await useFetchJson('/api/v1/auth/complete-registration', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: token.value }),
|
||||
})
|
||||
|
||||
success.value = true
|
||||
verificationInProgress.value = false
|
||||
|
||||
// Redirect to login after 3 seconds
|
||||
setTimeout(() => {
|
||||
router.push({ name: 'login' })
|
||||
}, 3000)
|
||||
} catch (e: any) {
|
||||
error.value = e?.message ?? tt('verify_email.verification_failed', 'Email verification failed')
|
||||
verificationInProgress.value = false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToLogin() {
|
||||
router.push({ name: 'login' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="min-h-screen bg-gradient-to-br from-primary-50 via-white to-primary-100 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
|
||||
<div class="pt-20 pb-8 flex items-center justify-center px-4 sm:px-6 lg:px-8">
|
||||
<div class="w-full max-w-md">
|
||||
<!-- Logo/Brand Section -->
|
||||
<div class="text-center mb-8">
|
||||
<div
|
||||
class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-500 text-white mb-4 shadow-lg shadow-primary-500/30">
|
||||
<UIcon name="i-heroicons-envelope-check" class="w-8 h-8" />
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">TimeTracker</h1>
|
||||
</div>
|
||||
|
||||
<!-- Verify Email Card -->
|
||||
<UCard class="shadow-xl shadow-gray-200/50 dark:shadow-gray-900/50">
|
||||
<template #header>
|
||||
<div class="text-center">
|
||||
<!-- Loading State -->
|
||||
<div v-if="verificationInProgress && loading">
|
||||
<UIcon name="i-heroicons-arrow-path" class="w-8 h-8 animate-spin text-primary-500 mx-auto mb-4" />
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ tt('verify_email.verifying', 'Verifying your email...') }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Success State -->
|
||||
<div v-else-if="success">
|
||||
<div
|
||||
class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-green-100 text-green-600 mb-4">
|
||||
<UIcon name="i-heroicons-check-circle" class="w-6 h-6" />
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ tt('verify_email.success_title', 'Email Verified!') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ tt('verify_email.success_message', 'Your email has been verified successfully.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error">
|
||||
<div
|
||||
class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-red-100 text-red-600 mb-4">
|
||||
<UIcon name="i-heroicons-exclamation-circle" class="w-6 h-6" />
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ tt('verify_email.error_title', 'Verification Failed') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ tt('verify_email.error_message', 'We could not verify your email.') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Success State Content -->
|
||||
<div v-if="success" class="text-center py-4">
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">{{ tt('verify_email.redirect_message', 'You will be redirected to login page...') }}</p>
|
||||
<UButton color="primary" @click="goToLogin">{{ tt('verify_email.go_to_login', 'Go to Login') }}</UButton>
|
||||
</div>
|
||||
|
||||
<!-- Error State Content -->
|
||||
<div v-else-if="error" class="text-center py-4">
|
||||
<UAlert :color="'error'" variant="subtle" icon="i-heroicons-exclamation-triangle" :title="error"
|
||||
class="mb-4" />
|
||||
<UButton color="primary" @click="goToLogin">{{ tt('verify_email.go_to_login', 'Go to Login') }}</UButton>
|
||||
</div>
|
||||
|
||||
<!-- Loading State Content -->
|
||||
<div v-else-if="verificationInProgress && loading" class="text-center py-4">
|
||||
<p class="text-gray-500 dark:text-gray-400">{{ tt('verify_email.please_wait', 'Please wait while we verify your email address.') }}</p>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ tt('verify_email.already_registered', 'Already have an account?') }}
|
||||
<UButton variant="link" size="sm" @click="goToLogin"> {{ tt('verify_email.sign_in', 'Sign in') }}
|
||||
</UButton>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user