sessions fix password change sign-in fix
This commit is contained in:
1 parent
63861449cf
commit
f0706432d4
20 files changed
+507
-85
No files matched your search
@@ -20,3 +20,12 @@ export async function itemsList(params: { page: number; limit: number }) {
|
|||||||
export async function itemGetById(id: string) {
|
export async function itemGetById(id: string) {
|
||||||
return apiClient.get<User>(`/api/users/${id}`)
|
return apiClient.get<User>(`/api/users/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** fileId — картинка, загруженная через fileUpload; прежний аватар сервер отдаст GC */
|
||||||
|
export async function avatarSet(fileId: string) {
|
||||||
|
return apiClient.put<User>('/api/users/me/avatar', { fileId })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function avatarRemove() {
|
||||||
|
return apiClient.delete('/api/users/me/avatar')
|
||||||
|
}
|
||||||
@@ -99,7 +99,6 @@ const select = (value: unknown) => {
|
|||||||
rounded="circle"
|
rounded="circle"
|
||||||
density="compact"
|
density="compact"
|
||||||
border
|
border
|
||||||
color="primary"
|
|
||||||
/>
|
/>
|
||||||
<div>{{ user.name || user.email }}</div>
|
<div>{{ user.name || user.email }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,34 +1,51 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
text?: string
|
text?: string
|
||||||
|
/** Shown instead of empty text, so there is still something to click */
|
||||||
|
placeholder?: string
|
||||||
|
/** Empty (or whitespace-only) text is not saved, the field stays open with an error */
|
||||||
|
required?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = withDefaults(defineProps<Props>(), { placeholder: 'click to write text', required: false })
|
||||||
|
|
||||||
|
const isEmpty = computed(() => !props.text?.trim())
|
||||||
const emit = defineEmits<{ changed: [text: string] }>()
|
const emit = defineEmits<{ changed: [text: string] }>()
|
||||||
|
|
||||||
const editing = ref(false)
|
const editing = ref(false)
|
||||||
const draft = ref(props.text ?? '')
|
const draft = ref(props.text ?? '')
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
watch(draft, () => {
|
||||||
|
error.value = ''
|
||||||
|
})
|
||||||
|
|
||||||
const startEdit = () => {
|
const startEdit = () => {
|
||||||
draft.value = props.text ?? ''
|
draft.value = props.text ?? ''
|
||||||
|
error.value = ''
|
||||||
editing.value = true
|
editing.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const save = () => {
|
const save = () => {
|
||||||
|
if (props.required && !draft.value.trim()) {
|
||||||
|
error.value = 'This field is required'
|
||||||
|
return
|
||||||
|
}
|
||||||
emit('changed', draft.value)
|
emit('changed', draft.value)
|
||||||
editing.value = false
|
editing.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const cancel = () => {
|
const cancel = () => {
|
||||||
draft.value = props.text ?? ''
|
draft.value = props.text ?? ''
|
||||||
|
error.value = ''
|
||||||
editing.value = false
|
editing.value = false
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="issue-title">
|
<div class="editable-text">
|
||||||
<template v-if="!editing">
|
<template v-if="!editing">
|
||||||
<v-hover #default="{ isHovering, props }">
|
<v-hover #default="{ isHovering, props }">
|
||||||
<v-sheet
|
<v-sheet
|
||||||
@@ -37,17 +54,26 @@ const cancel = () => {
|
|||||||
class="cursor-pointer d-flex align-center ga-1"
|
class="cursor-pointer d-flex align-center ga-1"
|
||||||
@click="startEdit"
|
@click="startEdit"
|
||||||
>
|
>
|
||||||
<div class="text-uppercase text-title-large">
|
<div class="text-uppercase text-title-large" :class="{ 'text-medium-emphasis': isEmpty }">
|
||||||
{{ text ?? 'click to write text' }}
|
{{ isEmpty ? placeholder : text }}
|
||||||
</div>
|
</div>
|
||||||
<v-icon v-if="isHovering" icon="mdi-pencil" size="small" />
|
<v-icon v-if="isHovering || isEmpty" icon="mdi-pencil" size="small" />
|
||||||
</v-sheet>
|
</v-sheet>
|
||||||
</v-hover>
|
</v-hover>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="d-flex ga-1 align-center">
|
<div class="d-flex ga-1 align-center">
|
||||||
<v-text-field autofocus class="mb-1" hide-details v-model="draft" variant="outlined" @keyup.enter="save" />
|
<v-text-field
|
||||||
|
autofocus
|
||||||
|
class="mb-1"
|
||||||
|
hide-details="auto"
|
||||||
|
:error-messages="error"
|
||||||
|
v-model="draft"
|
||||||
|
variant="outlined"
|
||||||
|
@keyup.enter="save"
|
||||||
|
@keyup.esc="cancel"
|
||||||
|
/>
|
||||||
|
|
||||||
<v-btn density="compact" icon variant="text" @click="cancel">
|
<v-btn density="compact" icon variant="text" @click="cancel">
|
||||||
<v-icon icon="mdi-cancel" />
|
<v-icon icon="mdi-cancel" />
|
||||||
@@ -7,7 +7,7 @@ import type { Issue } from '@/api/issue.api.ts'
|
|||||||
import StatusMenu from '@/components/StatusMenu.vue'
|
import StatusMenu from '@/components/StatusMenu.vue'
|
||||||
import AssigneeMenu from '@/components/AssigneeMenu.vue'
|
import AssigneeMenu from '@/components/AssigneeMenu.vue'
|
||||||
import ReporterInfo from '@/components/ReporterInfo.vue'
|
import ReporterInfo from '@/components/ReporterInfo.vue'
|
||||||
import IssueTitle from '@/components/IssueTitle.vue'
|
import EditableText from '@/components/EditableText.vue'
|
||||||
import IssueDate from '@/components/IssueDate.vue'
|
import IssueDate from '@/components/IssueDate.vue'
|
||||||
import CommentsList from '@/components/CommentsList.vue'
|
import CommentsList from '@/components/CommentsList.vue'
|
||||||
import HistoryList from '@/components/HistoryList.vue'
|
import HistoryList from '@/components/HistoryList.vue'
|
||||||
@@ -133,7 +133,7 @@ defineExpose({
|
|||||||
<div class="d-flex flex-column ga-4 details__main">
|
<div class="d-flex flex-column ga-4 details__main">
|
||||||
<div>{{ ckey }}</div>
|
<div>{{ ckey }}</div>
|
||||||
|
|
||||||
<issue-title :text="issue.title" @changed="onTitleChanged" />
|
<editable-text :text="issue.title" @changed="onTitleChanged" />
|
||||||
|
|
||||||
<v-card elevation="0" border>
|
<v-card elevation="0" border>
|
||||||
<v-card-text class="u-grid">
|
<v-card-text class="u-grid">
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ onMounted(async () => {
|
|||||||
<v-btn
|
<v-btn
|
||||||
class="mt-2"
|
class="mt-2"
|
||||||
variant="tonal"
|
variant="tonal"
|
||||||
|
density="comfortable"
|
||||||
prepend-icon="mdi-bell-ring-outline"
|
prepend-icon="mdi-bell-ring-outline"
|
||||||
:loading="testSending"
|
:loading="testSending"
|
||||||
@click="sendTestPush"
|
@click="sendTestPush"
|
||||||
|
|||||||
@@ -1,34 +1,141 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
import { authClient } from '@/lib/auth-client.js'
|
import { authClient } from '@/lib/auth-client.js'
|
||||||
|
import { useSnackbarStore } from '@/stores/snackbar.ts'
|
||||||
|
|
||||||
const passwordData = reactive({
|
const session = authClient.useSession()
|
||||||
|
const snackbarStore = useSnackbarStore()
|
||||||
|
|
||||||
|
const email = computed(() => session.value.data?.user.email ?? '')
|
||||||
|
|
||||||
|
const err = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const reset = reactive({
|
||||||
newPassword: '',
|
newPassword: '',
|
||||||
currentPassword: '',
|
OTP: '',
|
||||||
|
showOTP: false,
|
||||||
|
showPassword: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const changePassword = async () => {
|
const canReset = computed(() => reset.newPassword.length > 0 && reset.OTP.length === 6)
|
||||||
await authClient.changePassword({
|
|
||||||
newPassword: passwordData.newPassword,
|
watch(
|
||||||
currentPassword: passwordData.currentPassword,
|
() => reset.OTP,
|
||||||
revokeOtherSessions: true,
|
(val) => {
|
||||||
|
if (val.length >= 1) err.value = ''
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const sendOTP = async () => {
|
||||||
|
if (!email.value) return
|
||||||
|
loading.value = true
|
||||||
|
const { data, error } = await authClient.emailOtp.requestPasswordReset({
|
||||||
|
email: email.value,
|
||||||
})
|
})
|
||||||
|
loading.value = false
|
||||||
|
|
||||||
|
if (data?.success) {
|
||||||
|
reset.showOTP = true
|
||||||
|
err.value = ''
|
||||||
|
}
|
||||||
|
if (error) err.value = error.message ?? 'Failed to send code'
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmPasswordReset = async () => {
|
||||||
|
if (!canReset.value) return
|
||||||
|
loading.value = true
|
||||||
|
const { data, error } = await authClient.emailOtp.resetPassword({
|
||||||
|
email: email.value,
|
||||||
|
otp: reset.OTP,
|
||||||
|
password: reset.newPassword,
|
||||||
|
})
|
||||||
|
loading.value = false
|
||||||
|
|
||||||
|
if (data?.success) {
|
||||||
|
snackbarStore.add({ title: 'Password changed', text: '', color: 'success' })
|
||||||
|
reset.newPassword = ''
|
||||||
|
reset.OTP = ''
|
||||||
|
reset.showOTP = false
|
||||||
|
err.value = ''
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
err.value = error.message ?? 'Failed to reset password'
|
||||||
|
reset.OTP = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
reset.newPassword = ''
|
||||||
|
reset.OTP = ''
|
||||||
|
reset.showOTP = false
|
||||||
|
err.value = ''
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-sheet border class="pa-4 d-flex flex-column ga-2">
|
<v-sheet border class="pa-4 d-flex flex-column ga-2">
|
||||||
|
<div class="d-flex ga-2 align-center justify-space-between">
|
||||||
<div class="text-uppercase text-title-large">Change password</div>
|
<div class="text-uppercase text-title-large">Change password</div>
|
||||||
<div class="d-flex flex-column ga-2">
|
<v-chip v-if="err" size="small" variant="flat" color="error" density="compact">{{ err }}</v-chip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="reset.showOTP" class="d-flex flex-column ga-2">
|
||||||
|
<div class="text-body-medium">Enter the code sent to {{ email }} and a new password</div>
|
||||||
<v-text-field
|
<v-text-field
|
||||||
|
class="w-50"
|
||||||
clearable
|
clearable
|
||||||
bg-color="background"
|
bg-color="background"
|
||||||
placeholder="current password"
|
density="comfortable"
|
||||||
v-model="passwordData.currentPassword"
|
placeholder="New password"
|
||||||
|
prepend-inner-icon="mdi-lock"
|
||||||
|
:type="reset.showPassword ? 'text' : 'password'"
|
||||||
|
:append-inner-icon="reset.showPassword ? 'mdi-eye-off' : 'mdi-eye'"
|
||||||
|
@click:append-inner="reset.showPassword = !reset.showPassword"
|
||||||
|
v-model="reset.newPassword"
|
||||||
|
/>
|
||||||
|
<div class="d-flex ga-2 align-center">
|
||||||
|
<div>CODE from E-mail:</div>
|
||||||
|
<v-otp-input
|
||||||
|
class="justify-start pa-0"
|
||||||
|
color="background"
|
||||||
|
density="comfortable"
|
||||||
|
pattern="numeric"
|
||||||
|
v-model="reset.OTP"
|
||||||
|
@finish="confirmPasswordReset"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex ga-1">
|
||||||
|
<v-btn density="comfortable" text="Cancel" @click="cancel" />
|
||||||
|
<v-btn
|
||||||
|
prepend-icon="mdi-autorenew"
|
||||||
|
density="comfortable"
|
||||||
|
text="Resend Code"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="sendOTP"
|
||||||
|
/>
|
||||||
|
<v-btn
|
||||||
|
variant="tonal"
|
||||||
|
density="comfortable"
|
||||||
|
prepend-icon="mdi-check-circle"
|
||||||
|
text="Reset password"
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="!canReset"
|
||||||
|
@click="confirmPasswordReset"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="d-flex flex-column ga-2">
|
||||||
|
<div class="text-body-medium">A confirmation code will be sent to {{ email }}</div>
|
||||||
|
<div class="d-flex ga-1">
|
||||||
|
<v-btn
|
||||||
|
prepend-icon="mdi-email-fast-outline"
|
||||||
|
density="comfortable"
|
||||||
|
text="Send Code"
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="!email"
|
||||||
|
@click="sendOTP"
|
||||||
/>
|
/>
|
||||||
<v-text-field clearable bg-color="background" placeholder="new password" v-model="passwordData.newPassword" />
|
|
||||||
<div>
|
|
||||||
<v-btn variant="tonal" prepend-icon="mdi-check" text="Update" @click="changePassword" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</v-sheet>
|
</v-sheet>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { authClient } from '@/lib/auth-client.ts'
|
import { authClient } from '@/lib/auth-client.ts'
|
||||||
import { ref, watchEffect } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
interface Session {
|
interface Session {
|
||||||
id: string
|
id: string
|
||||||
@@ -13,6 +14,8 @@ interface Session {
|
|||||||
userAgent?: string | null | undefined
|
userAgent?: string | null | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const currentSession = authClient.useSession()
|
||||||
const sessions = ref<Session[]>([])
|
const sessions = ref<Session[]>([])
|
||||||
|
|
||||||
const listSessions = async () => {
|
const listSessions = async () => {
|
||||||
@@ -24,23 +27,32 @@ const listSessions = async () => {
|
|||||||
sessions.value = result.data
|
sessions.value = result.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// signOut also clears the session cookie and the cached useSession state
|
||||||
|
const signOutToSignIn = async () => {
|
||||||
|
await authClient.signOut()
|
||||||
|
router.push({ name: 'sign-in' })
|
||||||
|
}
|
||||||
|
|
||||||
const revokeSession = async (token: string) => {
|
const revokeSession = async (token: string) => {
|
||||||
await authClient.revokeSession({
|
if (token === currentSession.value.data?.session.token) return signOutToSignIn()
|
||||||
|
|
||||||
|
const { error } = await authClient.revokeSession({
|
||||||
token: token,
|
token: token,
|
||||||
})
|
})
|
||||||
|
if (!error) await listSessions()
|
||||||
}
|
}
|
||||||
|
|
||||||
const revokeOtherSessions = async () => {
|
const revokeOtherSessions = async () => {
|
||||||
await authClient.revokeOtherSessions()
|
const { error } = await authClient.revokeOtherSessions()
|
||||||
|
if (!error) await listSessions()
|
||||||
}
|
}
|
||||||
|
|
||||||
const revokeAllSessions = async () => {
|
const revokeAllSessions = async () => {
|
||||||
await authClient.revokeSessions()
|
await authClient.revokeSessions()
|
||||||
|
await signOutToSignIn()
|
||||||
}
|
}
|
||||||
|
|
||||||
watchEffect(async () => {
|
onMounted(listSessions)
|
||||||
await listSessions()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -62,8 +74,11 @@ watchEffect(async () => {
|
|||||||
</template>
|
</template>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
<div class="mb-2">
|
<div class="d-flex ga-2">
|
||||||
<v-btn prepend-icon="mdi-skull" @click="revokeOtherSessions()">Revoke other sessions</v-btn>
|
<v-btn density="comfortable" @click="revokeOtherSessions">Revoke other sessions</v-btn>
|
||||||
|
<v-btn color="error" variant="flat" density="comfortable" prepend-icon="mdi-skull" @click="revokeAllSessions">
|
||||||
|
Revoke all sessions
|
||||||
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
</v-sheet>
|
</v-sheet>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts"></script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-row justify="center">
|
||||||
|
<v-col cols="12" lg="6" md="7" sm="8">
|
||||||
|
<slot />
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<script setup lang="ts"></script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-sheet class="py-4 px-6">
|
||||||
|
<slot />
|
||||||
|
</v-sheet>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive } from 'vue'
|
import { reactive, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { authClient } from '@/lib/auth-client.js'
|
import { authClient } from '@/lib/auth-client.js'
|
||||||
import { redirectTarget } from '@/lib/redirect.ts'
|
import { redirectTarget } from '@/lib/redirect.ts'
|
||||||
|
import SignForm from '@/components/SignForm.vue'
|
||||||
|
|
||||||
const REMEMBERED_EMAIL_KEY = 'rememberedEmail'
|
const REMEMBERED_EMAIL_KEY = 'rememberedEmail'
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -14,6 +15,7 @@ const signInData = reactive({
|
|||||||
rememberMe: true,
|
rememberMe: true,
|
||||||
callbackURL: 'https://madsky.ru',
|
callbackURL: 'https://madsky.ru',
|
||||||
})
|
})
|
||||||
|
const err = ref('')
|
||||||
|
|
||||||
const signIn = async () => {
|
const signIn = async () => {
|
||||||
const { data, error } = await authClient.signIn.email({
|
const { data, error } = await authClient.signIn.email({
|
||||||
@@ -31,22 +33,63 @@ const signIn = async () => {
|
|||||||
}
|
}
|
||||||
router.push(redirectTarget(route.query.redirect))
|
router.push(redirectTarget(route.query.redirect))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if (error.message) {
|
||||||
|
err.value = error.message
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<sign-form>
|
||||||
<div class="d-flex flex-column ga-2">
|
<div class="d-flex flex-column ga-2">
|
||||||
<div class="text-uppercase">sign in</div>
|
<div class="d-flex ga-2 align-center justify-space-between">
|
||||||
<v-text-field prepend-inner-icon="mdi-email" placeholder="E-mail" v-model="signInData.email" />
|
<div class="text-uppercase">Sign In</div>
|
||||||
<v-text-field prepend-inner-icon="mdi-lock" placeholder="Password" v-model="signInData.password" />
|
<v-chip v-if="err" size="small" variant="flat" color="error" density="compact">{{ err }}</v-chip>
|
||||||
|
</div>
|
||||||
|
<v-text-field
|
||||||
|
bg-color="background"
|
||||||
|
clearable
|
||||||
|
prepend-inner-icon="mdi-email"
|
||||||
|
placeholder="E-mail"
|
||||||
|
v-model="signInData.email"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
bg-color="background"
|
||||||
|
clearable
|
||||||
|
prepend-inner-icon="mdi-lock"
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
v-model="signInData.password"
|
||||||
|
/>
|
||||||
<div class="d-flex ga-2 align-center">
|
<div class="d-flex ga-2 align-center">
|
||||||
<v-switch color="info" v-model="signInData.rememberMe" label="Remember me" />
|
<v-switch
|
||||||
|
true-icon="mdi-check"
|
||||||
|
false-icon="mdi-close"
|
||||||
|
inset="material"
|
||||||
|
color="info"
|
||||||
|
hide-details
|
||||||
|
size="x-small"
|
||||||
|
v-model="signInData.rememberMe"
|
||||||
|
label="Remember me"
|
||||||
|
/>
|
||||||
|
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<div>
|
<div>
|
||||||
<v-btn prepend-icon="mdi-login" density="comfortable" color="primary" @click="signIn">SIGN IN</v-btn>
|
<v-btn
|
||||||
|
prepend-icon="mdi-login"
|
||||||
|
density="comfortable"
|
||||||
|
variant="flat"
|
||||||
|
color="primary"
|
||||||
|
@click="signIn"
|
||||||
|
text="Sign In"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</sign-form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss"></style>
|
<style scoped lang="scss"></style>
|
||||||
@@ -3,12 +3,13 @@ import { reactive, ref, watch } from 'vue'
|
|||||||
import { authClient } from '@/lib/auth-client.js'
|
import { authClient } from '@/lib/auth-client.js'
|
||||||
import { redirectTarget } from '@/lib/redirect.ts'
|
import { redirectTarget } from '@/lib/redirect.ts'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import SignForm from '@/components/SignForm.vue'
|
||||||
|
|
||||||
const REMEMBERED_EMAIL_KEY = 'rememberedEmail'
|
const REMEMBERED_EMAIL_KEY = 'rememberedEmail'
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
const otpError = ref('')
|
const err = ref('')
|
||||||
const signIn = reactive({
|
const signIn = reactive({
|
||||||
email: localStorage.getItem(REMEMBERED_EMAIL_KEY) ?? '',
|
email: localStorage.getItem(REMEMBERED_EMAIL_KEY) ?? '',
|
||||||
OTP: '',
|
OTP: '',
|
||||||
@@ -25,7 +26,7 @@ const sendOTP = async () => {
|
|||||||
if (data?.success) {
|
if (data?.success) {
|
||||||
if (signIn.rememberMe) localStorage.setItem(REMEMBERED_EMAIL_KEY, signIn.email)
|
if (signIn.rememberMe) localStorage.setItem(REMEMBERED_EMAIL_KEY, signIn.email)
|
||||||
signIn.showOTP = true
|
signIn.showOTP = true
|
||||||
otpError.value = ''
|
err.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -36,7 +37,7 @@ const sendOTP = async () => {
|
|||||||
watch(
|
watch(
|
||||||
() => signIn.OTP,
|
() => signIn.OTP,
|
||||||
(val) => {
|
(val) => {
|
||||||
if (val.length >= 1) otpError.value = ''
|
if (val.length >= 1) err.value = ''
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -47,9 +48,9 @@ const checkOTP = async () => {
|
|||||||
})
|
})
|
||||||
if (data) router.push(redirectTarget(route.query.redirect))
|
if (data) router.push(redirectTarget(route.query.redirect))
|
||||||
if (error) {
|
if (error) {
|
||||||
if (error.status === 400 && error.message) otpError.value = error.message
|
if (error.status === 400 && error.message) err.value = error.message
|
||||||
if (error.status === 403 && error.message) {
|
if (error.status === 403 && error.message) {
|
||||||
otpError.value = error.message
|
err.value = error.message
|
||||||
signIn.showOTP = false
|
signIn.showOTP = false
|
||||||
}
|
}
|
||||||
signIn.OTP = ''
|
signIn.OTP = ''
|
||||||
@@ -58,12 +59,19 @@ const checkOTP = async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<sign-form>
|
||||||
<div class="d-flex flex-column ga-2">
|
<div class="d-flex flex-column ga-2">
|
||||||
<div class="d-flex ga-2 align-center justify-space-between">
|
<div class="d-flex ga-2 align-center justify-space-between">
|
||||||
<div class="text-uppercase">Sign In</div>
|
<div class="text-uppercase">Sign In</div>
|
||||||
<v-chip v-if="otpError" size="small" variant="flat" color="error" density="compact">{{ otpError }}</v-chip>
|
<v-chip v-if="err" size="small" variant="flat" color="error" density="compact">{{ err }}</v-chip>
|
||||||
</div>
|
</div>
|
||||||
<v-text-field prepend-inner-icon="mdi-email" density="comfortable" placeholder="E-mail" v-model="signIn.email" />
|
<v-text-field
|
||||||
|
clearable
|
||||||
|
prepend-inner-icon="mdi-email"
|
||||||
|
bg-color="background"
|
||||||
|
placeholder="E-mail"
|
||||||
|
v-model="signIn.email"
|
||||||
|
/>
|
||||||
<div v-if="signIn.showOTP">
|
<div v-if="signIn.showOTP">
|
||||||
<v-otp-input density="comfortable" autofocus pattern="numeric" v-model="signIn.OTP" @finish="checkOTP" />
|
<v-otp-input density="comfortable" autofocus pattern="numeric" v-model="signIn.OTP" @finish="checkOTP" />
|
||||||
<div class="d-flex ga-1 justify-end">
|
<div class="d-flex ga-1 justify-end">
|
||||||
@@ -74,6 +82,7 @@ const checkOTP = async () => {
|
|||||||
<v-btn prepend-icon="mdi-email-fast-outline" density="comfortable" @click="sendOTP" text="Send Code" />
|
<v-btn prepend-icon="mdi-email-fast-outline" density="comfortable" @click="sendOTP" text="Send Code" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</sign-form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss"></style>
|
<style scoped lang="scss"></style>
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, ref, useTemplateRef } from 'vue'
|
||||||
|
import { authClient } from '@/lib/auth-client.ts'
|
||||||
|
import { resizeImage } from '@/lib/image-resize.ts'
|
||||||
|
import { fileUpload } from '@/api/file.api.ts'
|
||||||
|
import { avatarRemove, avatarSet } from '@/api/user.api.ts'
|
||||||
|
import { useSnackbarStore } from '@/stores/snackbar.ts'
|
||||||
|
import EditableText from '@/components/EditableText.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user?: {
|
user?: {
|
||||||
@@ -13,20 +19,116 @@ interface Props {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The largest avatar is 128px in the profile, so 512px covers HiDPI screens */
|
||||||
|
const AVATAR_MAX_SIZE = 512
|
||||||
|
/** The server shows only these types inline */
|
||||||
|
const AVATAR_TYPES = 'image/png,image/jpeg,image/gif,image/webp'
|
||||||
|
|
||||||
const { user } = defineProps<Props>()
|
const { user } = defineProps<Props>()
|
||||||
|
|
||||||
|
const snackbarStore = useSnackbarStore()
|
||||||
|
const input = useTemplateRef<HTMLInputElement>('input')
|
||||||
|
const avatarSaving = ref(false)
|
||||||
|
|
||||||
const avatar = computed(() => {
|
const avatar = computed(() => {
|
||||||
return user?.image ?? undefined
|
return user?.image ?? undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function showError(text: string, e: unknown) {
|
||||||
|
snackbarStore.add({ title: text, text: e instanceof Error ? e.message : '', color: 'error' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// The avatar is changed by our API, not better-auth, so its 30s session cookie cache
|
||||||
|
// is stale: re-read the session from the DB, then let useSession() consumers refetch
|
||||||
|
async function refreshSession() {
|
||||||
|
await authClient.getSession({ query: { disableCookieCache: true } })
|
||||||
|
authClient.$store.notify('$sessionSignal')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveName(name: string) {
|
||||||
|
const trimmed = name.trim()
|
||||||
|
if (!trimmed || trimmed === user?.name) return
|
||||||
|
|
||||||
|
const { error } = await authClient.updateUser({ name: trimmed })
|
||||||
|
if (error) showError('Failed to change name', new Error(error.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPicker() {
|
||||||
|
input.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onInputChange(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
const file = target.files?.[0]
|
||||||
|
// Allow picking the same file again
|
||||||
|
target.value = ''
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
avatarSaving.value = true
|
||||||
|
try {
|
||||||
|
const stored = await fileUpload(await resizeImage(file, AVATAR_MAX_SIZE))
|
||||||
|
await avatarSet(stored.id)
|
||||||
|
await refreshSession()
|
||||||
|
} catch (e) {
|
||||||
|
showError('Failed to change avatar', e)
|
||||||
|
} finally {
|
||||||
|
avatarSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeAvatar() {
|
||||||
|
avatarSaving.value = true
|
||||||
|
try {
|
||||||
|
await avatarRemove()
|
||||||
|
await refreshSession()
|
||||||
|
} catch (e) {
|
||||||
|
showError('Failed to remove avatar', e)
|
||||||
|
} finally {
|
||||||
|
avatarSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-sheet border class="pa-4 d-flex flex-column ga-2">
|
<v-sheet border class="pa-4 d-flex flex-column ga-2">
|
||||||
<div v-if="user" class="d-flex ga-4">
|
<div v-if="user" class="d-flex ga-4">
|
||||||
<v-avatar size="x-large" color="info" :image="avatar" />
|
<v-menu>
|
||||||
|
<template #activator="{ props: menuProps }">
|
||||||
|
<v-hover #default="{ isHovering, props: hoverProps }">
|
||||||
|
<v-avatar v-bind="{ ...menuProps, ...hoverProps }" size="128" color="background" class="cursor-pointer">
|
||||||
|
<!-- With a default slot v-avatar ignores its image prop, so the image goes in the slot -->
|
||||||
|
<v-img v-if="avatar" :src="avatar" alt="" cover />
|
||||||
|
<v-icon v-else icon="mdi-account" size="64" />
|
||||||
|
<v-overlay
|
||||||
|
:model-value="isHovering || avatarSaving"
|
||||||
|
contained
|
||||||
|
persistent
|
||||||
|
scrim="black"
|
||||||
|
class="align-center justify-center"
|
||||||
|
>
|
||||||
|
<v-progress-circular v-if="avatarSaving" indeterminate size="40" width="3" color="white" />
|
||||||
|
<v-icon v-else icon="mdi-camera" size="40" color="white" />
|
||||||
|
</v-overlay>
|
||||||
|
</v-avatar>
|
||||||
|
</v-hover>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div>
|
<v-list density="compact">
|
||||||
<div>Name: {{ user.name }}</div>
|
<v-list-item prepend-icon="mdi-upload" title="Upload photo" :disabled="avatarSaving" @click="openPicker" />
|
||||||
|
<v-list-item
|
||||||
|
v-if="avatar"
|
||||||
|
prepend-icon="mdi-trash-can"
|
||||||
|
title="Remove photo"
|
||||||
|
base-color="error"
|
||||||
|
:disabled="avatarSaving"
|
||||||
|
@click="removeAvatar"
|
||||||
|
/>
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
<input ref="input" type="file" :accept="AVATAR_TYPES" hidden @change="onInputChange" />
|
||||||
|
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<editable-text class="mb-2" :text="user.name" placeholder="Enter your name" required @changed="saveName" />
|
||||||
<div>E-mail: {{ user.email }}</div>
|
<div>E-mail: {{ user.email }}</div>
|
||||||
<div>Created At: {{ user.createdAt }}</div>
|
<div>Created At: {{ user.createdAt }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ const title = 'Ulitka'
|
|||||||
|
|
||||||
const isAuth = computed(() => !!session.value.data)
|
const isAuth = computed(() => !!session.value.data)
|
||||||
|
|
||||||
|
/** Shown in the avatar until the user uploads a picture */
|
||||||
|
const initial = computed(() => {
|
||||||
|
const user = session.value.data?.user
|
||||||
|
return (user?.name.trim() || user?.email || '?').charAt(0).toUpperCase()
|
||||||
|
})
|
||||||
|
|
||||||
function goToSignIn() {
|
function goToSignIn() {
|
||||||
router.push({ path: '/sign-in' })
|
router.push({ path: '/sign-in' })
|
||||||
}
|
}
|
||||||
@@ -66,6 +72,7 @@ const fluid = computed(() => {
|
|||||||
|
|
||||||
<div v-if="!session.data">
|
<div v-if="!session.data">
|
||||||
<v-btn @click="goToSignIn">Sign In</v-btn>
|
<v-btn @click="goToSignIn">Sign In</v-btn>
|
||||||
|
<v-btn @click="goToSignUp">Sign Up</v-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="session.data" class="d-flex align-center">
|
<div v-if="session.data" class="d-flex align-center">
|
||||||
@@ -84,7 +91,8 @@ const fluid = computed(() => {
|
|||||||
<v-list nav density="compact" class="pa-1">
|
<v-list nav density="compact" class="pa-1">
|
||||||
<v-list-item density="compact">
|
<v-list-item density="compact">
|
||||||
<v-sheet border class="d-flex align-center ga-2 pa-2">
|
<v-sheet border class="d-flex align-center ga-2 pa-2">
|
||||||
<v-avatar rounded="circle" color="primary">A</v-avatar>
|
<!-- No slot content: with a default slot v-avatar ignores image; text is used only without image -->
|
||||||
|
<v-avatar rounded="circle" border :image="session.data.user.image ?? undefined" :text="initial" />
|
||||||
<div>
|
<div>
|
||||||
<div>{{ session.data.user.name }}</div>
|
<div>{{ session.data.user.name }}</div>
|
||||||
<div class="text-label-medium">{{ session.data.user.email }}</div>
|
<div class="text-label-medium">{{ session.data.user.email }}</div>
|
||||||
|
|||||||
@@ -4,6 +4,17 @@ interface ApiEnvelope<T> {
|
|||||||
message?: string | string[]
|
message?: string | string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function redirectToSignIn() {
|
||||||
|
// Imported lazily: the router pulls in views that depend on this module
|
||||||
|
const [{ default: router }, { authClient }] = await Promise.all([import('@/router'), import('@/lib/auth-client.ts')])
|
||||||
|
// Refetch so useSession() consumers drop the stale user
|
||||||
|
authClient.$store.notify('$sessionSignal')
|
||||||
|
|
||||||
|
const route = router.currentRoute.value
|
||||||
|
if (route.name === 'sign-in') return
|
||||||
|
await router.push({ name: 'sign-in', query: { redirect: route.fullPath } })
|
||||||
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
// FormData needs the browser to set multipart Content-Type with its boundary
|
// FormData needs the browser to set multipart Content-Type with its boundary
|
||||||
const isForm = init?.body instanceof FormData
|
const isForm = init?.body instanceof FormData
|
||||||
@@ -15,6 +26,12 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
|
|
||||||
if (res.status === 204) return undefined as T
|
if (res.status === 204) return undefined as T
|
||||||
|
|
||||||
|
// Session expired or was revoked from another device
|
||||||
|
if (res.status === 401) {
|
||||||
|
await redirectToSignIn()
|
||||||
|
throw new Error('Unauthorized')
|
||||||
|
}
|
||||||
|
|
||||||
// A proxy (e.g. nginx 413) may answer with a non-JSON body
|
// A proxy (e.g. nginx 413) may answer with a non-JSON body
|
||||||
const body = (await res.json().catch(() => ({}))) as Partial<ApiEnvelope<T>>
|
const body = (await res.json().catch(() => ({}))) as Partial<ApiEnvelope<T>>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Shrinks an image so its longest side is at most `maxSize` px before upload.
|
||||||
|
* GIFs are kept as is (canvas would drop the animation), as are images the
|
||||||
|
* browser cannot decode — the server decides what to do with those.
|
||||||
|
*/
|
||||||
|
export async function resizeImage(file: File, maxSize: number): Promise<File> {
|
||||||
|
if (file.type === 'image/gif') return file
|
||||||
|
|
||||||
|
let bitmap: ImageBitmap
|
||||||
|
try {
|
||||||
|
bitmap = await createImageBitmap(file)
|
||||||
|
} catch {
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale = Math.min(1, maxSize / Math.max(bitmap.width, bitmap.height))
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = Math.round(bitmap.width * scale)
|
||||||
|
canvas.height = Math.round(bitmap.height * scale)
|
||||||
|
canvas.getContext('2d')?.drawImage(bitmap, 0, 0, canvas.width, canvas.height)
|
||||||
|
bitmap.close()
|
||||||
|
|
||||||
|
// Browsers without WebP encoding (Safari) fall back to PNG
|
||||||
|
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/webp', 0.9))
|
||||||
|
if (!blob || blob.size >= file.size) return file
|
||||||
|
|
||||||
|
const ext = blob.type === 'image/webp' ? 'webp' : 'png'
|
||||||
|
return new File([blob], `${file.name.replace(/\.[^.]+$/, '')}.${ext}`, { type: blob.type })
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ const router = createRouter({
|
|||||||
{
|
{
|
||||||
path: '/sign-up',
|
path: '/sign-up',
|
||||||
component: SignUpView,
|
component: SignUpView,
|
||||||
|
meta: { breadcrumb: 'Sign-up' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/verify-email',
|
path: '/verify-email',
|
||||||
|
|||||||
@@ -11,13 +11,9 @@ const session = authClient.useSession()
|
|||||||
<template>
|
<template>
|
||||||
<div class="d-flex flex-column ga-2">
|
<div class="d-flex flex-column ga-2">
|
||||||
<div class="text-headline-medium text-uppercase">profile</div>
|
<div class="text-headline-medium text-uppercase">profile</div>
|
||||||
|
|
||||||
<user-profile :user="session.data?.user" />
|
<user-profile :user="session.data?.user" />
|
||||||
|
|
||||||
<password-change />
|
<password-change />
|
||||||
|
|
||||||
<notification-settings />
|
<notification-settings />
|
||||||
|
|
||||||
<sessions-list />
|
<sessions-list />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,34 +2,29 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import SignInOtp from '@/components/SignInOtp.vue'
|
import SignInOtp from '@/components/SignInOtp.vue'
|
||||||
import SignInEmail from '@/components/SignInEmail.vue'
|
import SignInEmail from '@/components/SignInEmail.vue'
|
||||||
|
import SignBlock from '@/components/SignBlock.vue'
|
||||||
|
|
||||||
const tab = ref('otp')
|
const tab = ref('otp')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-row justify="center">
|
<sign-block>
|
||||||
<v-col cols="12" lg="4" md="4" sm="8">
|
|
||||||
<v-sheet>
|
<v-sheet>
|
||||||
<v-tabs v-model="tab" color="primary" density="compact" align-tabs="center">
|
<v-tabs v-model="tab" color="primary" density="compact" align-tabs="center">
|
||||||
<v-tab value="otp">OTP</v-tab>
|
<v-tab prepend-icon="mdi-key" value="otp">OTP</v-tab>
|
||||||
<v-tab value="email">Email</v-tab>
|
<v-tab prepend-icon="mdi-mail" value="email">Email</v-tab>
|
||||||
</v-tabs>
|
</v-tabs>
|
||||||
|
|
||||||
<v-tabs-window v-model="tab">
|
<v-tabs-window v-model="tab">
|
||||||
<v-tabs-window-item value="otp">
|
<v-tabs-window-item value="otp">
|
||||||
<v-sheet class="pa-4">
|
|
||||||
<sign-in-otp />
|
<sign-in-otp />
|
||||||
</v-sheet>
|
|
||||||
</v-tabs-window-item>
|
</v-tabs-window-item>
|
||||||
<v-tabs-window-item value="email">
|
<v-tabs-window-item value="email">
|
||||||
<v-sheet class="pa-4">
|
|
||||||
<sign-in-email />
|
<sign-in-email />
|
||||||
</v-sheet>
|
|
||||||
</v-tabs-window-item>
|
</v-tabs-window-item>
|
||||||
</v-tabs-window>
|
</v-tabs-window>
|
||||||
</v-sheet>
|
</v-sheet>
|
||||||
</v-col>
|
</sign-block>
|
||||||
</v-row>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss"></style>
|
<style scoped lang="scss"></style>
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { authClient } from '@/lib/auth-client.js'
|
import { authClient } from '@/lib/auth-client.js'
|
||||||
import { reactive } from 'vue'
|
import { reactive, ref } from 'vue'
|
||||||
|
import SignForm from '@/components/SignForm.vue'
|
||||||
|
import SignBlock from '@/components/SignBlock.vue'
|
||||||
|
|
||||||
const signUpData = reactive({
|
const signUpData = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '', //vadim.olonin@gmail.com
|
||||||
password: '',
|
password: '',
|
||||||
})
|
})
|
||||||
|
const err = ref('')
|
||||||
|
|
||||||
const signUp = async () => {
|
const signUp = async () => {
|
||||||
const { data, error } = await authClient.signUp.email({
|
const { data, error } = await authClient.signUp.email({
|
||||||
@@ -14,32 +17,56 @@ const signUp = async () => {
|
|||||||
email: signUpData.email,
|
email: signUpData.email,
|
||||||
password: signUpData.password,
|
password: signUpData.password,
|
||||||
// image: 'https://example.com/image.png',
|
// image: 'https://example.com/image.png',
|
||||||
callbackURL: 'https://madsky.ru',
|
callbackURL: window.location.origin,
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log(data)
|
if (error) {
|
||||||
console.log(error)
|
if (error.message) {
|
||||||
|
err.value = error.message
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-row>
|
<sign-block>
|
||||||
<v-col cols="12" md="4" sm="8" offset-sm="2" offset-md="4" offset="0">
|
<sign-form>
|
||||||
<div class="d-flex flex-column ga-2">
|
<div class="d-flex flex-column ga-2">
|
||||||
|
<div class="d-flex ga-2 align-center justify-space-between">
|
||||||
<div class="text-uppercase">sign up</div>
|
<div class="text-uppercase">sign up</div>
|
||||||
<v-text-field placeholder="Name" prepend-inner-icon="mdi-account" v-model="signUpData.name" />
|
<v-chip v-if="err" size="small" variant="flat" color="error" density="compact">{{ err }}</v-chip>
|
||||||
<v-text-field placeholder="E-mail" prepend-inner-icon="mdi-email" v-model="signUpData.email" />
|
</div>
|
||||||
<v-text-field placeholder="Password" prepend-inner-icon="mdi-lock" v-model="signUpData.password" />
|
<v-text-field
|
||||||
|
bg-color="background"
|
||||||
|
clearable
|
||||||
|
placeholder="Name"
|
||||||
|
prepend-inner-icon="mdi-account"
|
||||||
|
v-model="signUpData.name"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
bg-color="background"
|
||||||
|
clearable
|
||||||
|
placeholder="E-mail"
|
||||||
|
prepend-inner-icon="mdi-email"
|
||||||
|
v-model="signUpData.email"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
bg-color="background"
|
||||||
|
clearable
|
||||||
|
placeholder="Password"
|
||||||
|
prepend-inner-icon="mdi-lock"
|
||||||
|
v-model="signUpData.password"
|
||||||
|
/>
|
||||||
<div class="d-flex ga-2 align-center">
|
<div class="d-flex ga-2 align-center">
|
||||||
<div>
|
<div>
|
||||||
<v-btn @click="signUp" color="primary" density="comfortable">SIGN UP</v-btn>
|
<v-btn @click="signUp" variant="flat" color="primary" density="comfortable">SIGN UP</v-btn>
|
||||||
</div>
|
</div>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<router-link to="/verify-email" class="text-warning text-decoration-none"> Verify Email </router-link>
|
<router-link to="/verify-email" class="text-warning text-decoration-none"> Verify Email </router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</v-col>
|
</sign-form>
|
||||||
</v-row>
|
</sign-block>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss"></style>
|
<style scoped lang="scss"></style>
|
||||||
@@ -1,35 +1,53 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { authClient } from '@/lib/auth-client.js'
|
import { authClient } from '@/lib/auth-client.js'
|
||||||
import { reactive } from 'vue'
|
import { reactive, ref } from 'vue'
|
||||||
|
import SignForm from '@/components/SignForm.vue'
|
||||||
|
import SignBlock from '@/components/SignBlock.vue'
|
||||||
|
|
||||||
const verifyData = reactive({
|
const verifyData = reactive({
|
||||||
email: '',
|
email: '',
|
||||||
})
|
})
|
||||||
|
const err = ref('')
|
||||||
|
|
||||||
const verify = async () => {
|
const verify = async () => {
|
||||||
const { data, error } = await authClient.sendVerificationEmail({
|
const { data, error } = await authClient.sendVerificationEmail({
|
||||||
email: verifyData.email,
|
email: verifyData.email,
|
||||||
callbackURL: 'https://madsky.ru',
|
callbackURL: window.location.origin,
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log(data)
|
console.log(data)
|
||||||
console.log(error)
|
console.log(error)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if (error.message) {
|
||||||
|
err.value = error.message
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-row>
|
<sign-block>
|
||||||
<v-col cols="12" md="6" sm="8" offset-sm="2" offset-md="3" offset="0">
|
<sign-form>
|
||||||
<div class="d-flex flex-column ga-2">
|
<div class="d-flex flex-column ga-2">
|
||||||
|
<div class="d-flex ga-2 align-center justify-space-between">
|
||||||
<div class="text-uppercase">verify email</div>
|
<div class="text-uppercase">verify email</div>
|
||||||
|
<v-chip v-if="err" size="small" variant="flat" color="error" density="compact">{{ err }}</v-chip>
|
||||||
|
</div>
|
||||||
|
|
||||||
<v-text-field placeholder="email" prepend-inner-icon="mdi-email" v-model="verifyData.email" />
|
<v-text-field
|
||||||
|
clearable
|
||||||
|
placeholder="email"
|
||||||
|
bg-color="background"
|
||||||
|
prepend-inner-icon="mdi-email"
|
||||||
|
v-model="verifyData.email"
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<v-btn @click="verify" color="primary" density="comfortable">VERIFY</v-btn>
|
<v-btn @click="verify" color="primary" variant="flat" density="comfortable">VERIFY</v-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</v-col>
|
</sign-form>
|
||||||
</v-row>
|
</sign-block>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss"></style>
|
<style scoped lang="scss"></style>
|
||||||
Reference in new issue
Block a user