Create sign-in and sign-out workflow in SPA
This commit is contained in:
parent
08a1b5b81d
commit
b6d1bd9ee2
15 changed files with 761 additions and 460 deletions
|
|
@ -1,34 +1,24 @@
|
|||
<template>
|
||||
<div class="app-container">
|
||||
<Sidebar v-if="$route.meta.showSidebar" />
|
||||
<MainContent>
|
||||
<div v-if="session.loading">Loading...</div>
|
||||
<div v-else-if="session.error">Error: {{ session.error }}</div>
|
||||
<router-view v-else />
|
||||
</MainContent>
|
||||
</div>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from "vue";
|
||||
import { useSessionStore } from "@/store/session";
|
||||
import { Session } from "@/type/api";
|
||||
|
||||
import Sidebar from "./components/layout/Sidebar.vue";
|
||||
import MainContent from "./components/layout/MainContent.vue";
|
||||
import NavigationLink from "@/components/common/NavigationLink.vue";
|
||||
import { router } from "@/router";
|
||||
|
||||
const session = useSessionStore();
|
||||
onMounted(() => {
|
||||
session.get().then((session: Session) => {
|
||||
console.log("session loaded", session);
|
||||
});
|
||||
session
|
||||
.get()
|
||||
.then((session: Session) => {
|
||||
console.log("session loaded", session);
|
||||
router.push("/_/dash");
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log("root session not loaded", e);
|
||||
});
|
||||
console.log("home mounted");
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
123
ts/client.ts
123
ts/client.ts
|
|
@ -1,31 +1,104 @@
|
|||
// src/api/axios.ts
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
import axios, { AxiosInstance, AxiosRequestConfig } from "axios";
|
||||
import router from "@/router";
|
||||
|
||||
// Extend the AxiosInstance interface
|
||||
declare module "axios" {
|
||||
interface AxiosInstance {
|
||||
isAuthenticated(): boolean;
|
||||
class ApiClient {
|
||||
private client: AxiosInstance;
|
||||
private _isAuthenticated: boolean = false;
|
||||
|
||||
constructor() {
|
||||
this.client = axios.create({
|
||||
timeout: 10000,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
// Request interceptor for auth headers, content-type, etc.
|
||||
this.client.interceptors.request.use((config) => {
|
||||
// Content-type negotiation
|
||||
config.headers["Accept"] = "application/json";
|
||||
config.headers["X-Requested-With"] = "nidus-web 0.1";
|
||||
|
||||
// Add auth token if logged in
|
||||
const token = localStorage.getItem("authToken");
|
||||
if (token) {
|
||||
config.headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
// Response interceptor for handling auth errors
|
||||
this.client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
this._isAuthenticated = false;
|
||||
// Could emit event or redirect here
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
get isAuthenticated(): boolean {
|
||||
return this._isAuthenticated;
|
||||
}
|
||||
|
||||
setLoggedIn(value: boolean): void {
|
||||
this._isAuthenticated = value;
|
||||
}
|
||||
|
||||
async JSONGet<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response = await this.client.get<T>(url, {
|
||||
...config,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...config?.headers,
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async JSONPost<T = any>(
|
||||
url: string,
|
||||
data?: any,
|
||||
config?: AxiosRequestConfig,
|
||||
): Promise<T> {
|
||||
const response = await this.client.post<T>(url, data, {
|
||||
...config,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
...config?.headers,
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async JSONPut<T = any>(
|
||||
url: string,
|
||||
data?: any,
|
||||
config?: AxiosRequestConfig,
|
||||
): Promise<T> {
|
||||
const response = await this.client.put<T>(url, data, {
|
||||
...config,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
...config?.headers,
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async JSONDelete<T = any>(
|
||||
url: string,
|
||||
config?: AxiosRequestConfig,
|
||||
): Promise<T> {
|
||||
const response = await this.client.delete<T>(url, config);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: "/api",
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
router.push("/login");
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
apiClient.isAuthenticated = () => {
|
||||
return true;
|
||||
};
|
||||
|
||||
export default apiClient;
|
||||
// Single instance export - this IS the singleton
|
||||
export const apiClient = new ApiClient();
|
||||
|
|
|
|||
123
ts/components/common/ButtonLoading.vue
Normal file
123
ts/components/common/ButtonLoading.vue
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
<style scoped>
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<button
|
||||
:class="buttonClasses"
|
||||
:disabled="disabled || loading"
|
||||
@click="handleClick"
|
||||
>
|
||||
<!-- Loading Spinner -->
|
||||
<span
|
||||
v-if="loading"
|
||||
class="spinner-border spinner-border-sm me-2"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
|
||||
<!-- Icon (only show when not loading) -->
|
||||
<i v-if="icon && !loading" :class="iconClasses"></i>
|
||||
|
||||
<!-- Button Text -->
|
||||
<span v-if="text">{{ text }}</span>
|
||||
|
||||
<!-- Slot for additional content -->
|
||||
<slot></slot>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
// Define props
|
||||
const props = defineProps({
|
||||
text: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: "primary",
|
||||
validator: (value) =>
|
||||
[
|
||||
"primary",
|
||||
"secondary",
|
||||
"success",
|
||||
"danger",
|
||||
"warning",
|
||||
"info",
|
||||
"light",
|
||||
"dark",
|
||||
"link",
|
||||
"outline-primary",
|
||||
"outline-secondary",
|
||||
"outline-success",
|
||||
"outline-danger",
|
||||
"outline-warning",
|
||||
"outline-info",
|
||||
"outline-light",
|
||||
"outline-dark",
|
||||
].includes(value),
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: "",
|
||||
validator: (value) => ["", "sm", "lg"].includes(value),
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
block: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Define emits
|
||||
const emit = defineEmits(["click"]);
|
||||
|
||||
// Computed classes for button
|
||||
const buttonClasses = computed(() => {
|
||||
return [
|
||||
"btn",
|
||||
`btn-${props.variant}`,
|
||||
{
|
||||
[`btn-${props.size}`]: props.size,
|
||||
"w-100": props.block,
|
||||
disabled: props.loading,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
// Computed classes for icon
|
||||
const iconClasses = computed(() => {
|
||||
return [
|
||||
props.icon,
|
||||
{ "me-2": props.text }, // Add margin if there's text
|
||||
];
|
||||
});
|
||||
|
||||
// Handle click event
|
||||
const handleClick = (event) => {
|
||||
if (!props.loading && !props.disabled) {
|
||||
emit("click", event);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
@ -1,11 +1,15 @@
|
|||
<template>
|
||||
<div id="content">
|
||||
<div v-if="session.loading">Loading...</div>
|
||||
<div v-else-if="session.error">Error: {{ session.error }}</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// No imports needed for this simple component
|
||||
import { useSessionStore } from "@/store/session";
|
||||
|
||||
const session = useSessionStore();
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
|
|||
|
|
@ -197,6 +197,9 @@
|
|||
label="Configuration"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<NavigationLink to="/signout" icon="door-open" label="Signout" />
|
||||
</li>
|
||||
<li>
|
||||
<NavigationLink to="/_/sudo" icon="god" label="Sudo" />
|
||||
</li>
|
||||
|
|
|
|||
368
ts/router.ts
368
ts/router.ts
|
|
@ -1,190 +1,207 @@
|
|||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import type { RouteRecordRaw } from "vue-router";
|
||||
import Home from "./view/Home.vue";
|
||||
import About from "./view/About.vue";
|
||||
import Communication from "./view/Communication.vue";
|
||||
import ConfigurationIntegration from "./view/configuration/Integration.vue";
|
||||
import ConfigurationIntegrationArcgis from "./view/configuration/IntegrationArcgis.vue";
|
||||
import ConfigurationOrganization from "./view/configuration/Organization.vue";
|
||||
import ConfigurationPesticide from "./view/configuration/Pesticide.vue";
|
||||
import ConfigurationPesticideAdd from "./view/configuration/PesticideAdd.vue";
|
||||
import ConfigurationRoot from "./view/configuration/Root.vue";
|
||||
import ConfigurationUpload from "./view/configuration/Upload.vue";
|
||||
import ConfigurationUploadDetail from "./view/configuration/UploadDetail.vue";
|
||||
import ConfigurationUploadPool from "./view/configuration/UploadPool.vue";
|
||||
import ConfigurationUploadPoolCustom from "./view/configuration/UploadPoolCustom.vue";
|
||||
import ConfigurationUploadPoolFlyover from "./view/configuration/UploadPoolFlyover.vue";
|
||||
import ConfigurationUser from "./view/configuration/User.vue";
|
||||
import ConfigurationUserAdd from "./view/configuration/UserAdd.vue";
|
||||
import ConfigurationUserEdit from "./view/configuration/UserEdit.vue";
|
||||
import Intelligence from "./view/Intelligence.vue";
|
||||
import NotFound from "./view/NotFound.vue";
|
||||
import OAuthRefreshArcgis from "./view/OAuthRefreshArcgis.vue";
|
||||
import Operations from "./view/Operations.vue";
|
||||
import Planning from "./view/Planning.vue";
|
||||
import ReviewPool from "./view/review/Pool.vue";
|
||||
import ReviewRoot from "./view/review/Root.vue";
|
||||
import ReviewSite from "./view/review/Site.vue";
|
||||
import Signin from "./view/Signin.vue";
|
||||
import Sudo from "./view/Sudo.vue";
|
||||
import apiClient from "@/client";
|
||||
import Home from "@/view/Home.vue";
|
||||
import Authenticated from "@/view/Authenticated.vue";
|
||||
import Communication from "@/view/Communication.vue";
|
||||
import ConfigurationIntegration from "@/view/configuration/Integration.vue";
|
||||
import ConfigurationIntegrationArcgis from "@/view/configuration/IntegrationArcgis.vue";
|
||||
import ConfigurationOrganization from "@/view/configuration/Organization.vue";
|
||||
import ConfigurationPesticide from "@/view/configuration/Pesticide.vue";
|
||||
import ConfigurationPesticideAdd from "@/view/configuration/PesticideAdd.vue";
|
||||
import ConfigurationRoot from "@/view/configuration/Root.vue";
|
||||
import ConfigurationUpload from "@/view/configuration/Upload.vue";
|
||||
import ConfigurationUploadDetail from "@/view/configuration/UploadDetail.vue";
|
||||
import ConfigurationUploadPool from "@/view/configuration/UploadPool.vue";
|
||||
import ConfigurationUploadPoolCustom from "@/view/configuration/UploadPoolCustom.vue";
|
||||
import ConfigurationUploadPoolFlyover from "@/view/configuration/UploadPoolFlyover.vue";
|
||||
import ConfigurationUser from "@/view/configuration/User.vue";
|
||||
import ConfigurationUserAdd from "@/view/configuration/UserAdd.vue";
|
||||
import ConfigurationUserEdit from "@/view/configuration/UserEdit.vue";
|
||||
import Dash from "@/view/Dash.vue";
|
||||
import Intelligence from "@/view/Intelligence.vue";
|
||||
import NotFound from "@/view/NotFound.vue";
|
||||
import OAuthRefreshArcgis from "@/view/OAuthRefreshArcgis.vue";
|
||||
import Operations from "@/view/Operations.vue";
|
||||
import Planning from "@/view/Planning.vue";
|
||||
import ReviewPool from "@/view/review/Pool.vue";
|
||||
import ReviewRoot from "@/view/review/Root.vue";
|
||||
import ReviewSite from "@/view/review/Site.vue";
|
||||
import Signin from "@/view/Signin.vue";
|
||||
import Signout from "@/view/Signout.vue";
|
||||
import Sudo from "@/view/Sudo.vue";
|
||||
import { apiClient } from "@/client";
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: "/",
|
||||
name: "Home",
|
||||
component: Home,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
path: "/_/communication",
|
||||
name: "Communication",
|
||||
component: Communication,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
children: [
|
||||
{
|
||||
path: "/_/communication",
|
||||
name: "Communication",
|
||||
component: Communication,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
path: "/_/configuration",
|
||||
name: "Configuration",
|
||||
component: ConfigurationRoot,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
path: "/_/configuration/integration",
|
||||
name: "Integration Configuration",
|
||||
component: ConfigurationIntegration,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
path: "/_/configuration/integration/arcgis",
|
||||
name: "Arcgis Integration Configuration",
|
||||
component: ConfigurationIntegrationArcgis,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
path: "/_/configuration/organization",
|
||||
name: "Organization Configuration",
|
||||
component: ConfigurationOrganization,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
path: "/_/configuration/pesticide",
|
||||
name: "Pesticide Configuration",
|
||||
component: ConfigurationPesticide,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
path: "/_/configuration/pesticide/add",
|
||||
name: "Pesticide Add",
|
||||
component: ConfigurationPesticideAdd,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
path: "/_/configuration/upload",
|
||||
name: "Upload Configuration",
|
||||
component: ConfigurationUpload,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
component: ConfigurationUploadDetail,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
name: "Upload Detail",
|
||||
path: "/_/configuration/upload/:id",
|
||||
props: (route) => ({
|
||||
id: parseInt(route.params.id as string, 10),
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/_/configuration/upload/pool",
|
||||
name: "Pool Upload",
|
||||
component: ConfigurationUploadPool,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
path: "/_/configuration/upload/pool/custom",
|
||||
name: "Custom Pool Upload",
|
||||
component: ConfigurationUploadPoolCustom,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
path: "/_/configuration/upload/pool/flyover",
|
||||
name: "Flyover Upload",
|
||||
component: ConfigurationUploadPoolFlyover,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
path: "/_/configuration/user",
|
||||
name: "User Configuration",
|
||||
component: ConfigurationUser,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
path: "/_/configuration/user/add",
|
||||
name: "User Add Configuration",
|
||||
component: ConfigurationUserAdd,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
component: ConfigurationUserEdit,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
name: "User Edit",
|
||||
path: "/_/configuration/user/:id",
|
||||
props: (route) => ({
|
||||
id: parseInt(route.params.id as string, 10),
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/_/dash",
|
||||
name: "Dash",
|
||||
component: Dash,
|
||||
},
|
||||
{
|
||||
path: "/_/intelligence",
|
||||
name: "Intelligence",
|
||||
component: Intelligence,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
path: "/_/oauth/refresh/arcgis",
|
||||
name: "Arcgis OAuth Refresh",
|
||||
component: OAuthRefreshArcgis,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
path: "/_/operations",
|
||||
name: "Operations",
|
||||
component: Operations,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
path: "/_/planning",
|
||||
name: "Planning",
|
||||
component: Planning,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
path: "/_/review",
|
||||
name: "Review",
|
||||
component: ReviewRoot,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
path: "/_/review/pool",
|
||||
name: "Pool Review",
|
||||
component: ReviewPool,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
path: "/_/review/site",
|
||||
name: "Site Review",
|
||||
component: ReviewSite,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
path: "/_/sudo",
|
||||
name: "Sudo",
|
||||
component: Sudo,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
],
|
||||
component: Authenticated,
|
||||
path: "/_",
|
||||
name: "Authenticated",
|
||||
},
|
||||
{
|
||||
path: "/_/configuration",
|
||||
name: "Configuration",
|
||||
component: ConfigurationRoot,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
path: "/_/configuration/integration",
|
||||
name: "Integration Configuration",
|
||||
component: ConfigurationIntegration,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
path: "/_/configuration/integration/arcgis",
|
||||
name: "Arcgis Integration Configuration",
|
||||
component: ConfigurationIntegrationArcgis,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
path: "/_/configuration/organization",
|
||||
name: "Organization Configuration",
|
||||
component: ConfigurationOrganization,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
path: "/_/configuration/pesticide",
|
||||
name: "Pesticide Configuration",
|
||||
component: ConfigurationPesticide,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
path: "/_/configuration/pesticide/add",
|
||||
name: "Pesticide Add",
|
||||
component: ConfigurationPesticideAdd,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
path: "/_/configuration/upload",
|
||||
name: "Upload Configuration",
|
||||
component: ConfigurationUpload,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
component: ConfigurationUploadDetail,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
name: "Upload Detail",
|
||||
path: "/_/configuration/upload/:id",
|
||||
props: (route) => ({
|
||||
id: parseInt(route.params.id as string, 10),
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/_/configuration/upload/pool",
|
||||
name: "Pool Upload",
|
||||
component: ConfigurationUploadPool,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
path: "/_/configuration/upload/pool/custom",
|
||||
name: "Custom Pool Upload",
|
||||
component: ConfigurationUploadPoolCustom,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
path: "/_/configuration/upload/pool/flyover",
|
||||
name: "Flyover Upload",
|
||||
component: ConfigurationUploadPoolFlyover,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
path: "/_/configuration/user",
|
||||
name: "User Configuration",
|
||||
component: ConfigurationUser,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
path: "/_/configuration/user/add",
|
||||
name: "User Add Configuration",
|
||||
component: ConfigurationUserAdd,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
component: ConfigurationUserEdit,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
name: "User Edit",
|
||||
path: "/_/configuration/user/:id",
|
||||
props: (route) => ({
|
||||
id: parseInt(route.params.id as string, 10),
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/_/intelligence",
|
||||
name: "Intelligence",
|
||||
component: Intelligence,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
path: "/_/oauth/refresh/arcgis",
|
||||
name: "Arcgis OAuth Refresh",
|
||||
component: OAuthRefreshArcgis,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
path: "/_/operations",
|
||||
name: "Operations",
|
||||
component: Operations,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
path: "/_/planning",
|
||||
name: "Planning",
|
||||
component: Planning,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
path: "/_/review",
|
||||
name: "Review",
|
||||
component: ReviewRoot,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
path: "/_/review/pool",
|
||||
name: "Pool Review",
|
||||
component: ReviewPool,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
path: "/_/review/site",
|
||||
name: "Site Review",
|
||||
component: ReviewSite,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
},
|
||||
{
|
||||
path: "/_/signin",
|
||||
name: "Signin",
|
||||
component: Signin,
|
||||
meta: { requiresAuth: false, showSidebar: false },
|
||||
name: "Signin",
|
||||
path: "/signin",
|
||||
},
|
||||
{
|
||||
path: "/_/sudo",
|
||||
name: "Sudo",
|
||||
component: Sudo,
|
||||
meta: { requiresAuth: true, showSidebar: true },
|
||||
component: Signout,
|
||||
name: "Signout",
|
||||
path: "/signout",
|
||||
},
|
||||
// Catch-all route - must be last
|
||||
{
|
||||
|
|
@ -206,15 +223,14 @@ router.beforeEach(async (to, from) => {
|
|||
if (requiresAuth) {
|
||||
try {
|
||||
// Check if user is authenticated (could be an API call)
|
||||
const isAuthenticated = await apiClient.isAuthenticated();
|
||||
if (!isAuthenticated) {
|
||||
return "/signin";
|
||||
if (!apiClient.isAuthenticated) {
|
||||
return "/_/signin";
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("check auth failed");
|
||||
return "/signin";
|
||||
return "/_/signin";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,12 +8,13 @@ import {
|
|||
URLs,
|
||||
User,
|
||||
} from "@/type/api";
|
||||
import { apiClient } from "@/client";
|
||||
|
||||
export const useSessionStore = defineStore("session", () => {
|
||||
// State
|
||||
const impersonating = ref<string | null>(null);
|
||||
const error = ref<string | null>(null);
|
||||
const loading = ref(false);
|
||||
const loading = ref(true);
|
||||
const current = ref<Session | null>(null);
|
||||
const notification_counts = ref<SessionNotificationCounts | null>(null);
|
||||
const ongoingFetch = ref<Promise<Session> | null>(null);
|
||||
|
|
@ -34,10 +35,7 @@ export const useSessionStore = defineStore("session", () => {
|
|||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/session");
|
||||
if (!response.ok) throw new Error("Failed to fetch user");
|
||||
|
||||
const data: Session = await response.json();
|
||||
const data: Session = await apiClient.JSONGet("/api/session");
|
||||
impersonating.value = data.impersonating || null;
|
||||
notification_counts.value = data.notification_counts;
|
||||
organization.value = data.organization;
|
||||
|
|
@ -50,6 +48,7 @@ export const useSessionStore = defineStore("session", () => {
|
|||
throw new Error(error.value);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
console.log("no longer loading session");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -72,6 +71,9 @@ export const useSessionStore = defineStore("session", () => {
|
|||
ongoingFetch.value = null;
|
||||
return s;
|
||||
}
|
||||
async function signout(): Promise<void> {
|
||||
apiClient.JSONPost("/api/signout", {});
|
||||
}
|
||||
return {
|
||||
// State
|
||||
error,
|
||||
|
|
@ -85,5 +87,6 @@ export const useSessionStore = defineStore("session", () => {
|
|||
fetchSession,
|
||||
get,
|
||||
isAuthenticated,
|
||||
signout,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
27
ts/view/Authenticated.vue
Normal file
27
ts/view/Authenticated.vue
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<style scoped>
|
||||
.app-container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<template v-if="session.loading">Loading...</template>
|
||||
<template v-else-if="session.error">Error: {{ session.error }}</template>
|
||||
<template v-else>
|
||||
<Sidebar />
|
||||
<MainContent>
|
||||
<router-view v-slot="{ Component }">
|
||||
<component :is="Component" />
|
||||
</router-view>
|
||||
</MainContent>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useSessionStore } from "@/store/session";
|
||||
|
||||
const session = useSessionStore();
|
||||
import Sidebar from "@/components/layout/Sidebar.vue";
|
||||
import MainContent from "@/components/layout/MainContent.vue";
|
||||
</script>
|
||||
202
ts/view/Dash.vue
Normal file
202
ts/view/Dash.vue
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
<template>
|
||||
<!-- Dashboard Header -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<h1>{{ dashboard.organization.name }} Dashboard</h1>
|
||||
<p class="text-muted">
|
||||
Overview of mosquito control activities in your district
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="col-md-6 text-md-end d-flex align-items-center justify-content-md-end"
|
||||
>
|
||||
<p v-if="dashboard.isSyncOngoing" class="last-refreshed mb-0">
|
||||
<i class="fas fa-sync-alt me-2 syncing"></i>Syncing now...
|
||||
</p>
|
||||
<p v-else class="last-refreshed mb-0">
|
||||
<i class="fas fa-sync-alt me-2"></i>Last updated:
|
||||
<span id="last-refreshed-time">{{
|
||||
formatTimeRelative(dashboard.lastSync)
|
||||
}}</span>
|
||||
<button
|
||||
class="btn btn-sm btn-outline-primary ms-3"
|
||||
@click="refreshData"
|
||||
>
|
||||
Refresh Data
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key Metrics -->
|
||||
<div class="row g-4">
|
||||
<!-- Last Refreshed -->
|
||||
<div class="col-md-3">
|
||||
<div class="card stats-card h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="metric-icon bg-success text-white">
|
||||
<i class="fas fa-clock"></i>
|
||||
</div>
|
||||
<h5 class="card-title">Last Data Refresh</h5>
|
||||
<p class="metric-value">
|
||||
{{ formatTimeRelative(dashboard.lastSync) }}
|
||||
</p>
|
||||
<!-- <p class="card-text text-muted">Last sync: 12:45 PM</p> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Service Requests -->
|
||||
<div class="col-md-3">
|
||||
<div class="card stats-card h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="metric-icon bg-warning text-white">
|
||||
<i class="fas fa-ticket-alt"></i>
|
||||
</div>
|
||||
<h5 class="card-title">Service Requests</h5>
|
||||
<p v-if="dashboard.isSyncOngoing" class="metric-value">
|
||||
{{ formatBigNumber(dashboard.counts.service_requests) }}...?
|
||||
</p>
|
||||
<p v-else class="metric-value">
|
||||
{{ formatBigNumber(dashboard.counts.service_requests) }}
|
||||
</p>
|
||||
<!--<p class="card-text text-muted">
|
||||
<span class="text-success">
|
||||
<i class="fas fa-arrow-up"></i> 12%
|
||||
</span> since last week
|
||||
</p>-->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mosquito Sources -->
|
||||
<div class="col-md-3">
|
||||
<div class="card stats-card h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="metric-icon bg-danger text-white">
|
||||
<i class="fas fa-bug"></i>
|
||||
</div>
|
||||
<h5 class="card-title">Mosquito Sources</h5>
|
||||
<p v-if="dashboard.isSyncOngoing" class="metric-value">
|
||||
{{ formatBigNumber(dashboard.counts.mosquito_sources) }}..?
|
||||
</p>
|
||||
<p v-else class="metric-value">
|
||||
{{ formatBigNumber(dashboard.counts.mosquito_sources) }}
|
||||
</p>
|
||||
<!-- <p class="card-text text-muted">
|
||||
<span class="text-danger">
|
||||
<i class="fas fa-arrow-up"></i> 8%
|
||||
</span> since last month
|
||||
</p> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inspections -->
|
||||
<div class="col-md-3">
|
||||
<div class="card stats-card h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="metric-icon bg-info text-white">
|
||||
<i class="fas fa-clipboard-check"></i>
|
||||
</div>
|
||||
<h5 class="card-title">Traps</h5>
|
||||
<p v-if="dashboard.isSyncOngoing" class="metric-value">
|
||||
{{ formatBigNumber(dashboard.counts.traps) }}...?
|
||||
</p>
|
||||
<p v-else class="metric-value">
|
||||
{{ formatBigNumber(dashboard.counts.traps) }}
|
||||
</p>
|
||||
<!-- <p class="card-text text-muted">
|
||||
<span class="text-success">
|
||||
<i class="fas fa-arrow-up"></i> 15%
|
||||
</span> since last week
|
||||
</p> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Section -->
|
||||
<h3 class="section-title">Mosquito Activity Heatmap</h3>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<MapAggregate
|
||||
:bounds="mapBounds()"
|
||||
:markers="[]"
|
||||
:organizationId="dashboard.organization.id"
|
||||
:tegola="session.urls?.tegola ?? ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity Section -->
|
||||
<h3 class="section-title">Recent Activity</h3>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Type</th>
|
||||
<th>Location</th>
|
||||
<th>Status</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive } from "vue";
|
||||
import MapAggregate from "@/components/MapAggregate.vue";
|
||||
import { formatBigNumber, formatTimeRelative } from "@/format";
|
||||
import { useSessionStore } from "@/store/session";
|
||||
import { useStoreServiceRequest } from "@/store/service_request";
|
||||
import { useStoreSync } from "@/store/sync";
|
||||
import type { Bounds } from "@/type/api";
|
||||
|
||||
const dashboard = reactive({
|
||||
counts: {
|
||||
service_requests: 0,
|
||||
mosquito_sources: 0,
|
||||
traps: 0,
|
||||
},
|
||||
organization: {
|
||||
name: "",
|
||||
id: 0,
|
||||
},
|
||||
isSyncOngoing: false,
|
||||
lastSync: new Date(),
|
||||
tegolaUrl: "",
|
||||
serviceArea: {
|
||||
min: { x: 0, y: 0 },
|
||||
max: { x: 0, y: 0 },
|
||||
},
|
||||
recentRequests: [],
|
||||
});
|
||||
const storeServiceRequest = useStoreServiceRequest();
|
||||
const storeSync = useStoreSync();
|
||||
const session = useSessionStore();
|
||||
onMounted(async () => {
|
||||
const service_requests = await storeServiceRequest.fetchAll();
|
||||
const syncs = await storeSync.fetchAll();
|
||||
console.log("syncs", syncs);
|
||||
});
|
||||
function mapBounds(): Bounds | undefined {
|
||||
if (session.organization?.service_area) {
|
||||
return session.organization?.service_area;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
function refreshData() {
|
||||
console.log("fake refresh");
|
||||
}
|
||||
</script>
|
||||
209
ts/view/Home.vue
209
ts/view/Home.vue
|
|
@ -1,202 +1,23 @@
|
|||
<template>
|
||||
<!-- Dashboard Header -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<h1>{{ dashboard.organization.name }} Dashboard</h1>
|
||||
<p class="text-muted">
|
||||
Overview of mosquito control activities in your district
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="col-md-6 text-md-end d-flex align-items-center justify-content-md-end"
|
||||
>
|
||||
<p v-if="dashboard.isSyncOngoing" class="last-refreshed mb-0">
|
||||
<i class="fas fa-sync-alt me-2 syncing"></i>Syncing now...
|
||||
</p>
|
||||
<p v-else class="last-refreshed mb-0">
|
||||
<i class="fas fa-sync-alt me-2"></i>Last updated:
|
||||
<span id="last-refreshed-time">{{
|
||||
formatTimeRelative(dashboard.lastSync)
|
||||
}}</span>
|
||||
<button
|
||||
class="btn btn-sm btn-outline-primary ms-3"
|
||||
@click="refreshData"
|
||||
>
|
||||
Refresh Data
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key Metrics -->
|
||||
<div class="row g-4">
|
||||
<!-- Last Refreshed -->
|
||||
<div class="col-md-3">
|
||||
<div class="card stats-card h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="metric-icon bg-success text-white">
|
||||
<i class="fas fa-clock"></i>
|
||||
</div>
|
||||
<h5 class="card-title">Last Data Refresh</h5>
|
||||
<p class="metric-value">
|
||||
{{ formatTimeRelative(dashboard.lastSync) }}
|
||||
</p>
|
||||
<!-- <p class="card-text text-muted">Last sync: 12:45 PM</p> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Service Requests -->
|
||||
<div class="col-md-3">
|
||||
<div class="card stats-card h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="metric-icon bg-warning text-white">
|
||||
<i class="fas fa-ticket-alt"></i>
|
||||
</div>
|
||||
<h5 class="card-title">Service Requests</h5>
|
||||
<p v-if="dashboard.isSyncOngoing" class="metric-value">
|
||||
{{ formatBigNumber(dashboard.counts.service_requests) }}...?
|
||||
</p>
|
||||
<p v-else class="metric-value">
|
||||
{{ formatBigNumber(dashboard.counts.service_requests) }}
|
||||
</p>
|
||||
<!--<p class="card-text text-muted">
|
||||
<span class="text-success">
|
||||
<i class="fas fa-arrow-up"></i> 12%
|
||||
</span> since last week
|
||||
</p>-->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mosquito Sources -->
|
||||
<div class="col-md-3">
|
||||
<div class="card stats-card h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="metric-icon bg-danger text-white">
|
||||
<i class="fas fa-bug"></i>
|
||||
</div>
|
||||
<h5 class="card-title">Mosquito Sources</h5>
|
||||
<p v-if="dashboard.isSyncOngoing" class="metric-value">
|
||||
{{ formatBigNumber(dashboard.counts.mosquito_sources) }}..?
|
||||
</p>
|
||||
<p v-else class="metric-value">
|
||||
{{ formatBigNumber(dashboard.counts.mosquito_sources) }}
|
||||
</p>
|
||||
<!-- <p class="card-text text-muted">
|
||||
<span class="text-danger">
|
||||
<i class="fas fa-arrow-up"></i> 8%
|
||||
</span> since last month
|
||||
</p> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inspections -->
|
||||
<div class="col-md-3">
|
||||
<div class="card stats-card h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="metric-icon bg-info text-white">
|
||||
<i class="fas fa-clipboard-check"></i>
|
||||
</div>
|
||||
<h5 class="card-title">Traps</h5>
|
||||
<p v-if="dashboard.isSyncOngoing" class="metric-value">
|
||||
{{ formatBigNumber(dashboard.counts.traps) }}...?
|
||||
</p>
|
||||
<p v-else class="metric-value">
|
||||
{{ formatBigNumber(dashboard.counts.traps) }}
|
||||
</p>
|
||||
<!-- <p class="card-text text-muted">
|
||||
<span class="text-success">
|
||||
<i class="fas fa-arrow-up"></i> 15%
|
||||
</span> since last week
|
||||
</p> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Section -->
|
||||
<h3 class="section-title">Mosquito Activity Heatmap</h3>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<MapAggregate
|
||||
:bounds="mapBounds()"
|
||||
:markers="[]"
|
||||
:organizationId="dashboard.organization.id"
|
||||
:tegola="session.urls?.tegola ?? ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity Section -->
|
||||
<h3 class="section-title">Recent Activity</h3>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Type</th>
|
||||
<th>Location</th>
|
||||
<th>Status</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p>loading home...</p>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive } from "vue";
|
||||
import MapAggregate from "@/components/MapAggregate.vue";
|
||||
import { formatBigNumber, formatTimeRelative } from "@/format";
|
||||
import { onMounted } from "vue";
|
||||
import { useSessionStore } from "@/store/session";
|
||||
import { useStoreServiceRequest } from "@/store/service_request";
|
||||
import { useStoreSync } from "@/store/sync";
|
||||
import type { Bounds } from "@/type/api";
|
||||
import { Session } from "@/type/api";
|
||||
import { router } from "@/router";
|
||||
|
||||
const dashboard = reactive({
|
||||
counts: {
|
||||
service_requests: 0,
|
||||
mosquito_sources: 0,
|
||||
traps: 0,
|
||||
},
|
||||
organization: {
|
||||
name: "",
|
||||
id: 0,
|
||||
},
|
||||
isSyncOngoing: false,
|
||||
lastSync: new Date(),
|
||||
tegolaUrl: "",
|
||||
serviceArea: {
|
||||
min: { x: 0, y: 0 },
|
||||
max: { x: 0, y: 0 },
|
||||
},
|
||||
recentRequests: [],
|
||||
});
|
||||
const storeServiceRequest = useStoreServiceRequest();
|
||||
const storeSync = useStoreSync();
|
||||
const session = useSessionStore();
|
||||
onMounted(async () => {
|
||||
const service_requests = await storeServiceRequest.fetchAll();
|
||||
const syncs = await storeSync.fetchAll();
|
||||
console.log("syncs", syncs);
|
||||
onMounted(() => {
|
||||
session
|
||||
.get()
|
||||
.then((session: Session) => {
|
||||
console.log("session loaded", session);
|
||||
router.push("/_/dash");
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log("root session not loaded", e);
|
||||
});
|
||||
console.log("home mounted");
|
||||
});
|
||||
function mapBounds(): Bounds | undefined {
|
||||
if (session.organization?.service_area) {
|
||||
return session.organization?.service_area;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
function refreshData() {
|
||||
console.log("fake refresh");
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -33,43 +33,48 @@
|
|||
<p class="text-muted">Please enter your credentials</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/signin">
|
||||
<input type="hidden" name="next" value="none" />
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
name="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<input type="hidden" name="next" value="none" />
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
name="username"
|
||||
required
|
||||
v-model="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
name="password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
name="password"
|
||||
v-model="password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
<!--
|
||||
<div class="alert alert-danger" role="alert">
|
||||
The credentials you provided weren't recognized.
|
||||
</div>
|
||||
-->
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-primary">Login</button>
|
||||
</div>
|
||||
<div class="d-grid gap-2">
|
||||
<ButtonLoading
|
||||
@click="doLogin()"
|
||||
:loading="loading"
|
||||
text="Login"
|
||||
variant="primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 text-center">
|
||||
<p>Don't have an account? <a href="/signup">Sign up</a></p>
|
||||
<a href="forgot-password.html">Forgot password?</a>
|
||||
</div>
|
||||
</form>
|
||||
<div class="mt-3 text-center">
|
||||
<p>Don't have an account? <a href="/signup">Sign up</a></p>
|
||||
<a href="forgot-password.html">Forgot password?</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right side: Product Information -->
|
||||
|
|
@ -99,3 +104,27 @@
|
|||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { apiClient } from "@/client";
|
||||
import ButtonLoading from "@/components/common/ButtonLoading.vue";
|
||||
import { router } from "@/router";
|
||||
|
||||
const loading = ref<boolean>(false);
|
||||
const password = ref<string>("");
|
||||
const username = ref<string>("");
|
||||
async function doLogin() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const resp = await apiClient.JSONPost("/api/signin", {
|
||||
password: password.value,
|
||||
username: username.value,
|
||||
});
|
||||
router.push("/");
|
||||
} catch (e) {
|
||||
console.log("login failed", e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue