feat: initial Vue web app with OTP login

This commit is contained in:
2026-05-18 16:02:43 +03:30
commit 26e59e03e0
19 changed files with 2256 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
<template>
<main class="card">
<header class="home-header">
<div>
<h1 class="logo">مون‌تایم</h1>
<p class="subtitle" style="margin: 0">خوش آمدید</p>
</div>
<button type="button" class="btn btn-ghost" style="width: auto" @click="logout">
خروج
</button>
</header>
<dl v-if="auth.user" class="user-card">
<dt>موبایل</dt>
<dd dir="ltr">{{ auth.user.mobile }}</dd>
<dt>نام</dt>
<dd>{{ auth.user.fullName ?? '—' }}</dd>
<dt>نقش</dt>
<dd>{{ auth.user.role }}</dd>
</dl>
</main>
</template>
<script setup lang="ts">
import { onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
const auth = useAuthStore();
const router = useRouter();
onMounted(async () => {
await auth.loadUser();
});
function logout() {
auth.logout();
router.push({ name: 'login' });
}
</script>
+59
View File
@@ -0,0 +1,59 @@
<template>
<main class="card">
<h1 class="logo">مون‌تایم</h1>
<p class="subtitle">ورود با شماره موبایل</p>
<p v-if="error" class="error">{{ error }}</p>
<form @submit.prevent="onSubmit">
<div class="field">
<label for="mobile">شماره موبایل</label>
<input
id="mobile"
v-model="mobile"
type="tel"
inputmode="numeric"
placeholder="09121234567"
autocomplete="tel"
required
/>
</div>
<button class="btn btn-primary" type="submit" :disabled="loading">
{{ loading ? 'در حال ارسال…' : 'دریافت کد تأیید' }}
</button>
</form>
</main>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { requestOtp } from '@/api/auth';
import { ApiError } from '@/api/client';
const router = useRouter();
const mobile = ref('');
const loading = ref(false);
const error = ref('');
async function onSubmit() {
error.value = '';
loading.value = true;
try {
const res = await requestOtp(mobile.value.trim());
await router.push({
name: 'verify',
query: {
mobile: mobile.value.trim(),
expires: String(res.expiresInSeconds),
...(res.otpCode ? { devCode: res.otpCode } : {}),
},
});
} catch (e) {
error.value =
e instanceof ApiError ? e.message : 'خطا در ارسال درخواست. دوباره تلاش کنید.';
} finally {
loading.value = false;
}
}
</script>
+127
View File
@@ -0,0 +1,127 @@
<template>
<main class="card">
<h1 class="logo">کد تأیید</h1>
<p class="subtitle">کد ارسال‌شده به {{ mobile }} را وارد کنید</p>
<p v-if="devCode" class="error" style="border-color: var(--accent); color: var(--accent)">
حالت توسعه — کد: {{ devCode }}
</p>
<p v-if="error" class="error">{{ error }}</p>
<form @submit.prevent="onSubmit">
<div class="field">
<label for="code">کد ۶ رقمی</label>
<input
id="code"
v-model="code"
type="text"
inputmode="numeric"
maxlength="8"
placeholder="123456"
autocomplete="one-time-code"
required
/>
</div>
<button class="btn btn-primary" type="submit" :disabled="loading">
{{ loading ? 'در حال بررسی…' : 'ورود' }}
</button>
</form>
<p v-if="secondsLeft > 0" class="timer">
ارسال مجدد تا {{ secondsLeft }} ثانیه
</p>
<button
v-else
type="button"
class="btn btn-ghost"
:disabled="resendLoading"
@click="resend"
>
{{ resendLoading ? 'در حال ارسال…' : 'ارسال مجدد کد' }}
</button>
<button type="button" class="btn btn-ghost" @click="router.push({ name: 'login' })">
تغییر شماره موبایل
</button>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { requestOtp, verifyOtp } from '@/api/auth';
import { ApiError } from '@/api/client';
import { useAuthStore } from '@/stores/auth';
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const mobile = computed(() => String(route.query.mobile ?? ''));
const devCode = computed(() => route.query.devCode as string | undefined);
const code = ref(devCode.value ?? '');
const loading = ref(false);
const resendLoading = ref(false);
const error = ref('');
const secondsLeft = ref(Number(route.query.expires) || 120);
let timer: ReturnType<typeof setInterval> | null = null;
onMounted(() => {
if (!mobile.value) {
router.replace({ name: 'login' });
return;
}
timer = setInterval(() => {
if (secondsLeft.value > 0) {
secondsLeft.value -= 1;
}
}, 1000);
});
onUnmounted(() => {
if (timer) {
clearInterval(timer);
}
});
async function onSubmit() {
error.value = '';
loading.value = true;
try {
const res = await verifyOtp(mobile.value, code.value.trim());
auth.setSession(res.accessToken, res.user);
await router.replace({ name: 'home' });
} catch (e) {
error.value =
e instanceof ApiError ? e.message : 'کد نامعتبر است. دوباره تلاش کنید.';
} finally {
loading.value = false;
}
}
async function resend() {
error.value = '';
resendLoading.value = true;
try {
const res = await requestOtp(mobile.value);
secondsLeft.value = res.expiresInSeconds;
if (res.otpCode) {
await router.replace({
query: {
...route.query,
devCode: res.otpCode,
expires: String(res.expiresInSeconds),
},
});
}
} catch (e) {
error.value =
e instanceof ApiError ? e.message : 'ارسال مجدد ناموفق بود.';
} finally {
resendLoading.value = false;
}
}
</script>