feat(auth): enforce login route with global middleware

This commit is contained in:
Ruslan Bakiev
2026-02-23 12:01:03 +07:00
parent 5918a0593d
commit 43960d0374
4 changed files with 109 additions and 54 deletions

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted } from "vue";
import CrmAuthLoading from "~~/app/components/workspace/auth/CrmAuthLoading.vue";
import CrmAuthLoginForm from "~~/app/components/workspace/auth/CrmAuthLoginForm.vue";
import CrmCalendarPanel from "~~/app/components/workspace/calendar/CrmCalendarPanel.vue";
import CrmCommunicationsContextSidebar from "~~/app/components/workspace/communications/CrmCommunicationsContextSidebar.vue";
import CrmCommunicationsListSidebar from "~~/app/components/workspace/communications/CrmCommunicationsListSidebar.vue";
@@ -13,7 +12,6 @@ import meQuery from "~~/graphql/operations/me.graphql?raw";
import chatMessagesQuery from "~~/graphql/operations/chat-messages.graphql?raw";
import dashboardQuery from "~~/graphql/operations/dashboard.graphql?raw";
import getClientTimelineQuery from "~~/graphql/operations/get-client-timeline.graphql?raw";
import loginMutation from "~~/graphql/operations/login.graphql?raw";
import logoutMutation from "~~/graphql/operations/logout.graphql?raw";
import logPilotNoteMutation from "~~/graphql/operations/log-pilot-note.graphql?raw";
import createCalendarEventMutation from "~~/graphql/operations/create-calendar-event.graphql?raw";
@@ -533,10 +531,6 @@ const chatArchivingId = ref("");
const chatThreadPickerOpen = ref(false);
const commPinToggling = ref(false);
const selectedChatId = ref("");
const loginPhone = ref("");
const loginPassword = ref("");
const loginError = ref<string | null>(null);
const loginBusy = ref(false);
let pilotBackgroundPoll: ReturnType<typeof setInterval> | null = null;
const lifecycleNowMs = ref(Date.now());
let lifecycleClock: ReturnType<typeof setInterval> | null = null;
@@ -898,23 +892,7 @@ async function loadMe() {
const authResolved = ref(false);
async function bootstrapSession() {
try {
await loadMe();
if (!authMe.value) {
stopCrmRealtime();
pilotMessages.value = [];
chatConversations.value = [];
clientTimelineItems.value = [];
telegramConnectStatus.value = "not_connected";
telegramConnections.value = [];
telegramConnectUrl.value = "";
return;
}
await Promise.all([loadPilotMessages(), loadChatConversations(), refreshCrmData(), loadTelegramConnectStatus()]);
if (process.client) {
startCrmRealtime();
}
} catch {
const resetAuthState = () => {
stopCrmRealtime();
authMe.value = null;
pilotMessages.value = [];
@@ -923,6 +901,26 @@ async function bootstrapSession() {
telegramConnectStatus.value = "not_connected";
telegramConnections.value = [];
telegramConnectUrl.value = "";
};
try {
await loadMe();
if (!authMe.value) {
resetAuthState();
if (process.client) {
await navigateTo("/login", { replace: true });
}
return;
}
await Promise.all([loadPilotMessages(), loadChatConversations(), refreshCrmData(), loadTelegramConnectStatus()]);
if (process.client) {
startCrmRealtime();
}
} catch {
resetAuthState();
if (process.client) {
await navigateTo("/login", { replace: true });
}
} finally {
authResolved.value = true;
}
@@ -963,25 +961,6 @@ async function archiveChatConversation(id: string) {
}
}
async function login() {
loginError.value = null;
loginBusy.value = true;
try {
await gqlFetch<{ login: { ok: boolean } }>(loginMutation, {
phone: loginPhone.value,
password: loginPassword.value,
});
await loadMe();
startPilotBackgroundPolling();
startCrmRealtime();
await Promise.all([loadPilotMessages(), loadChatConversations(), refreshCrmData(), loadTelegramConnectStatus()]);
} catch (e: any) {
loginError.value = e?.data?.message || e?.message || "Login failed";
} finally {
loginBusy.value = false;
}
}
async function logout() {
await gqlFetch<{ logout: { ok: boolean } }>(logoutMutation);
stopCrmRealtime();
@@ -996,6 +975,9 @@ async function logout() {
telegramConnectStatus.value = "not_connected";
telegramConnections.value = [];
telegramConnectUrl.value = "";
if (process.client) {
await navigateTo("/login", { replace: true });
}
}
async function refreshCrmData() {
@@ -4781,18 +4763,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
<template>
<div class="h-[100dvh] overflow-hidden bg-base-200/35">
<CrmAuthLoading v-if="!authResolved" />
<CrmAuthLoginForm
v-else-if="!authMe"
:phone="loginPhone"
:password="loginPassword"
:error="loginError"
:busy="loginBusy"
@update:phone="loginPhone = $event"
@update:password="loginPassword = $event"
@submit="login"
/>
<CrmAuthLoading v-if="!authResolved || !authMe" />
<template v-else>
<div class="grid h-full min-h-0 grid-cols-1 gap-0 lg:grid-cols-[320px_minmax(0,1fr)]">

View File

@@ -0,0 +1,20 @@
export default defineNuxtRouteMiddleware(async (to) => {
const isLoginRoute = to.path === "/login";
const fetcher = import.meta.server ? useRequestFetch() : $fetch;
let authenticated = false;
try {
await fetcher("/api/auth/session", { method: "GET" });
authenticated = true;
} catch {
authenticated = false;
}
if (!authenticated && !isLoginRoute) {
return navigateTo("/login");
}
if (authenticated && isLoginRoute) {
return navigateTo("/");
}
});

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import CrmAuthLoginForm from "~~/app/components/workspace/auth/CrmAuthLoginForm.vue";
import loginMutation from "~~/graphql/operations/login.graphql?raw";
const phone = ref("");
const password = ref("");
const error = ref<string | null>(null);
const busy = ref(false);
async function gqlFetch<TData>(query: string, variables?: Record<string, unknown>) {
const headers = process.server ? useRequestHeaders(["cookie"]) : undefined;
const result = await $fetch<{ data?: TData; errors?: Array<{ message: string }> }>("/api/graphql", {
method: "POST",
headers,
body: { query, variables },
});
if (result.errors?.length) {
throw new Error(result.errors[0]?.message || "GraphQL request failed");
}
if (!result.data) {
throw new Error("GraphQL returned empty payload");
}
return result.data;
}
async function submit() {
error.value = null;
busy.value = true;
try {
await gqlFetch<{ login: { ok: boolean } }>(loginMutation, {
phone: phone.value,
password: password.value,
});
await navigateTo("/", { replace: true });
} catch (e: any) {
error.value = e?.data?.message || e?.message || "Login failed";
} finally {
busy.value = false;
}
}
</script>
<template>
<div class="h-[100dvh] overflow-hidden bg-base-200/35">
<CrmAuthLoginForm
:phone="phone"
:password="password"
:error="error"
:busy="busy"
@update:phone="phone = $event"
@update:password="password = $event"
@submit="submit"
/>
</div>
</template>

View File

@@ -0,0 +1,6 @@
import { getAuthContext } from "../../utils/auth";
export default defineEventHandler(async (event) => {
await getAuthContext(event);
return { authenticated: true };
});