From b6d1bd9ee2d17f56664dab6e67acfb766d576a1b Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Thu, 16 Apr 2026 17:14:57 +0000 Subject: [PATCH] Create sign-in and sign-out workflow in SPA --- api/handler.go | 12 + api/routes.go | 1 + api/signin.go | 5 + ts/AppSync.vue | 34 +-- ts/client.ts | 123 +++++++-- ts/components/common/ButtonLoading.vue | 123 +++++++++ ts/components/layout/MainContent.vue | 6 +- ts/components/layout/Sidebar.vue | 3 + ts/router.ts | 368 +++++++++++++------------ ts/store/session.ts | 13 +- ts/view/Authenticated.vue | 27 ++ ts/view/Dash.vue | 202 ++++++++++++++ ts/view/Home.vue | 209 +------------- ts/view/Signin.vue | 87 ++++-- vite/sync/vite.config.js | 8 - 15 files changed, 761 insertions(+), 460 deletions(-) create mode 100644 ts/components/common/ButtonLoading.vue create mode 100644 ts/view/Authenticated.vue create mode 100644 ts/view/Dash.vue diff --git a/api/handler.go b/api/handler.go index 8267db29..88e5506a 100644 --- a/api/handler.go +++ b/api/handler.go @@ -24,6 +24,7 @@ type ErrorAPI struct { var decoder = schema.NewDecoder() type handlerBase func(context.Context, http.ResponseWriter, *http.Request) *nhttp.ErrorWithStatus +type handlerBaseAuthenticated func(context.Context, http.ResponseWriter, *http.Request, platform.User) *nhttp.ErrorWithStatus type handlerFunctionDelete func(context.Context, *http.Request, platform.User) *nhttp.ErrorWithStatus type handlerFunctionGet[T any] func(context.Context, *http.Request, resource.QueryParams) (*T, *nhttp.ErrorWithStatus) type handlerFunctionGetAuthenticated[T any] func(context.Context, *http.Request, platform.User, resource.QueryParams) (*T, *nhttp.ErrorWithStatus) @@ -35,6 +36,17 @@ type handlerFunctionPostAuthenticated[RequestType any, ResponseType any] func(co type handlerFunctionPostFormMultipart[RequestType any, ResponseType any] func(context.Context, *http.Request, RequestType) (*ResponseType, *nhttp.ErrorWithStatus) type handlerFunctionPutAuthenticated[RequestType any] func(context.Context, *http.Request, platform.User, RequestType) (string, *nhttp.ErrorWithStatus) +func authenticatedHandlerBasic(f handlerBaseAuthenticated) http.Handler { + return auth.NewEnsureAuth(func(w http.ResponseWriter, r *http.Request, u platform.User) { + ctx := r.Context() + e := f(ctx, w, r, u) + if e != nil { + respondErrorStatus(w, e) + return + } + return + }) +} func authenticatedHandlerDelete(f handlerFunctionDelete) http.Handler { return auth.NewEnsureAuth(func(w http.ResponseWriter, r *http.Request, u platform.User) { ctx := r.Context() diff --git a/api/routes.go b/api/routes.go index fb63b050..3feab45c 100644 --- a/api/routes.go +++ b/api/routes.go @@ -12,6 +12,7 @@ func AddRoutes(r *mux.Router) { //r.Use(render.SetContentType(render.ContentTypeJSON)) // Unauthenticated endpoints r.HandleFunc("/signin", handlerJSONPost(postSignin)) + r.Handle("/signout", authenticatedHandlerBasic(postSignout)) r.HandleFunc("/signup", handlerJSONPost(postSignup)) // Authenticated endpoints r.Handle("/audio/{uuid}", auth.NewEnsureAuth(apiAudioPost)).Methods("POST") diff --git a/api/signin.go b/api/signin.go index ccc615bd..eef77a01 100644 --- a/api/signin.go +++ b/api/signin.go @@ -7,6 +7,7 @@ import ( "github.com/Gleipnir-Technology/nidus-sync/auth" nhttp "github.com/Gleipnir-Technology/nidus-sync/http" + "github.com/Gleipnir-Technology/nidus-sync/platform" "github.com/rs/zerolog/log" ) @@ -36,3 +37,7 @@ func postSignin(ctx context.Context, r *http.Request, req reqSignin) (string, *n } return "/", nil } +func postSignout(ctx context.Context, w http.ResponseWriter, r *http.Request, u platform.User) *nhttp.ErrorWithStatus { + auth.SignoutUser(r, u) + return nil +} diff --git a/ts/AppSync.vue b/ts/AppSync.vue index a1c49121..c642a843 100644 --- a/ts/AppSync.vue +++ b/ts/AppSync.vue @@ -1,34 +1,24 @@ - - diff --git a/ts/client.ts b/ts/client.ts index ed0e61d8..567db466 100644 --- a/ts/client.ts +++ b/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(url: string, config?: AxiosRequestConfig): Promise { + const response = await this.client.get(url, { + ...config, + headers: { + Accept: "application/json", + ...config?.headers, + }, + }); + return response.data; + } + + async JSONPost( + url: string, + data?: any, + config?: AxiosRequestConfig, + ): Promise { + const response = await this.client.post(url, data, { + ...config, + headers: { + "Content-Type": "application/json", + Accept: "application/json", + ...config?.headers, + }, + }); + return response.data; + } + + async JSONPut( + url: string, + data?: any, + config?: AxiosRequestConfig, + ): Promise { + const response = await this.client.put(url, data, { + ...config, + headers: { + "Content-Type": "application/json", + Accept: "application/json", + ...config?.headers, + }, + }); + return response.data; + } + + async JSONDelete( + url: string, + config?: AxiosRequestConfig, + ): Promise { + const response = await this.client.delete(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(); diff --git a/ts/components/common/ButtonLoading.vue b/ts/components/common/ButtonLoading.vue new file mode 100644 index 00000000..a9ab2c2d --- /dev/null +++ b/ts/components/common/ButtonLoading.vue @@ -0,0 +1,123 @@ + + + + diff --git a/ts/components/layout/MainContent.vue b/ts/components/layout/MainContent.vue index 84320ada..d4506d71 100644 --- a/ts/components/layout/MainContent.vue +++ b/ts/components/layout/MainContent.vue @@ -1,11 +1,15 @@ diff --git a/ts/components/layout/Sidebar.vue b/ts/components/layout/Sidebar.vue index 87107d55..55612c0d 100644 --- a/ts/components/layout/Sidebar.vue +++ b/ts/components/layout/Sidebar.vue @@ -197,6 +197,9 @@ label="Configuration" /> +
  • + +
  • diff --git a/ts/router.ts b/ts/router.ts index eb3c54c8..a25c746e 100644 --- a/ts/router.ts +++ b/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"; } } }); diff --git a/ts/store/session.ts b/ts/store/session.ts index a38b5988..7d841211 100644 --- a/ts/store/session.ts +++ b/ts/store/session.ts @@ -8,12 +8,13 @@ import { URLs, User, } from "@/type/api"; +import { apiClient } from "@/client"; export const useSessionStore = defineStore("session", () => { // State const impersonating = ref(null); const error = ref(null); - const loading = ref(false); + const loading = ref(true); const current = ref(null); const notification_counts = ref(null); const ongoingFetch = ref | 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 { + apiClient.JSONPost("/api/signout", {}); + } return { // State error, @@ -85,5 +87,6 @@ export const useSessionStore = defineStore("session", () => { fetchSession, get, isAuthenticated, + signout, }; }); diff --git a/ts/view/Authenticated.vue b/ts/view/Authenticated.vue new file mode 100644 index 00000000..7345f811 --- /dev/null +++ b/ts/view/Authenticated.vue @@ -0,0 +1,27 @@ + + + diff --git a/ts/view/Dash.vue b/ts/view/Dash.vue new file mode 100644 index 00000000..e348bf58 --- /dev/null +++ b/ts/view/Dash.vue @@ -0,0 +1,202 @@ + + + diff --git a/ts/view/Home.vue b/ts/view/Home.vue index e348bf58..34d3f56d 100644 --- a/ts/view/Home.vue +++ b/ts/view/Home.vue @@ -1,202 +1,23 @@ - diff --git a/ts/view/Signin.vue b/ts/view/Signin.vue index 4407889b..3dc0c8c8 100644 --- a/ts/view/Signin.vue +++ b/ts/view/Signin.vue @@ -33,43 +33,48 @@

    Please enter your credentials

    -
    - -
    - - -
    + +
    + + +
    -
    - - -
    +
    + + +
    - -
    - -
    +
    + +
    -
    -

    Don't have an account? Sign up

    - Forgot password? -
    -
    +
    +

    Don't have an account? Sign up

    + Forgot password? +
    @@ -99,3 +104,27 @@ + diff --git a/vite/sync/vite.config.js b/vite/sync/vite.config.js index 024b4896..4ac7b0c2 100644 --- a/vite/sync/vite.config.js +++ b/vite/sync/vite.config.js @@ -85,14 +85,6 @@ export default defineConfig({ target: "http://127.0.0.1:9003", changeOrigin: false, }, - "/signin": { - target: "http://localhost:9003", - changeOrigin: false, - }, - "/signup": { - target: "http://localhost:9003", - changeOrigin: false, - }, }, strictPort: true, },