Began transition to Vue3.

Implemented logging in and logging out.

Implemented authenticated http client.

Laid some groundwork for SCSS.
This commit is contained in:
2021-07-09 21:47:40 -05:00
parent 54b1565537
commit 3d3c43b944
284 changed files with 20487 additions and 38338 deletions

View File

@@ -0,0 +1,70 @@
<template>
<div class="content light">
<nav class="navbar" id="nav">
<div class="container-fluid">
<router-link class="navbar-brand" to="/">MultiShop</router-link>
<button
class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarContent"
>
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<router-link
active-class="active"
class="nav-link"
to="/"
>Home</router-link
>
</li>
<li class="nav-item">
<router-link
active-class="active"
class="nav-link"
to="/about"
>About</router-link
>
</li>
</ul>
<ul class="navbar-nav ms-auto mb-2 mb-lg-0">
<li class="nav-item">
<ProfileDisplay>
</ProfileDisplay>
</li>
<li class="nav-item">
<ProfileLogIn>
</ProfileLogIn>
</li>
<li class="nav-item">
<ProfileLogOut>
</ProfileLogOut>
</li>
</ul>
</div>
</div>
</nav>
<router-view />
</div>
</template>
<script>
import "bootstrap/js/dist/collapse";
import "./assets/scss/main.scss";
import ProfileDisplay from "./components/ProfileDisplay.vue";
import ProfileLogIn from "./components/ProfileLogIn.vue";
import ProfileLogOut from "./components/ProfileLogOut.vue";
export default {
components: {
ProfileDisplay,
ProfileLogIn,
ProfileLogOut
},
mounted() {
this.$store.dispatch("attemptSilentAuthentication");
}
};
</script>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -0,0 +1,10 @@
@use "../../../node_modules/bootstrap/scss/bootstrap";
#app .content.light {
// Add light theme specific scss here.
nav.navbar {
@extend .navbar-expand-lg;
@extend .navbar-light;
@extend .bg-light;
}
}

View File

@@ -0,0 +1,12 @@
@use "light";
@import "../../../node_modules/bootstrap-icons/font/bootstrap-icons.css";
html, body, #app, #app > .content {
min-height: 100%;
height: 100%;
}
.content {
display: flex;
flex-direction: column;
}

View File

@@ -0,0 +1,50 @@
<template>
<a v-if="visible" href="authentication/profile" class="btn">
<slot v-if="username" :displayName="username" name="username">
{{ username }}
</slot>
<slot v-else-if="isProfileLoading" name="loading">
<WaitCircle class="spinner-grow-sm"></WaitCircle>
</slot>
<slot v-else name="unauthenticated">
<span>Not Logged In</span>
</slot>
</a>
</template>
<script>
import WaitCircle from "@/components/WaitCircle.vue";
export default {
components: {
WaitCircle
},
name: "ProfileDisplay",
props: {
showUnauthenticated: {
type: Boolean,
default: false,
required: false
}
},
data() {
return {};
},
computed: {
username() {
return this.$store.getters.username &&
!this.$store.state.identity.loading
? this.$store.getters.username
: null;
},
visible() {
return !this.showUnauthenticated
? this.$store.getters.isAuthenticated ||
this.$store.state.identity.loading
: true;
},
isProfileLoading() {
return this.$store.state.identity.loading;
}
}
};
</script>

View File

@@ -0,0 +1,21 @@
<template>
<button v-if="visible" @click="onClick" type="button" class="btn">
<slot>Log In</slot>
</button>
</template>
<script>
export default {
name: "ProfileLogin",
computed: {
visible() {
return !this.$store.getters.isAuthenticated && !this.$store.state.identity.loading;
}
},
methods: {
onClick() {
this.$store.dispatch("beginAuthentication");
}
}
};
</script>

View File

@@ -0,0 +1,21 @@
<template>
<button v-if="visible" @click="onClick" type="button" class="btn">
<slot>Log Out</slot>
</button>
</template>
<script>
export default {
name: "ProfileLogOut",
computed: {
visible() {
return this.$store.getters.isAuthenticated && !this.$store.state.identity.loading;
}
},
methods: {
onClick() {
this.$store.dispatch("beginDeauthentication");
}
}
};
</script>

View File

@@ -0,0 +1,39 @@
<template>
<form autocomplete="off">
<div class="input-group">
<input type="text" class="form-control" :placeholder="placeholder" v-model="query" @keyup.enter="onEnter">
<slot name="append"></slot>
<button class="btn btn-outline-primary" type="button" @click="onClick">
<i class="bi bi-search"></i>
</button>
</div>
</form>
</template>
<script>
export default {
props: {
placeholder: String,
searchCallback: Function
},
data() {
return {
query: "",
disabled: false
};
},
methods: {
onClick() {
this.disabled = true;
this.searchCallback(this.query);
this.disabled = false;
},
onEnter() {
this.disabled = true;
this.searchCallback(this.query);
this.disabled = false;
}
}
};
</script>

View File

@@ -0,0 +1,24 @@
<template>
<div>
<div class="mx-auto" id="spinner-container">
<div class="spinner-grow" role="status" v-bind="$attrs">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div>
<slot></slot>
</div>
</div>
</template>
<script>
export default {
inheritAttrs: false
};
</script>
<style lang="scss" scoped>
#spinner-container {
width: fit-content;
}
</style>

View File

@@ -0,0 +1,13 @@
import { createApp } from "vue";
import App from "./App.vue";
import "./registerServiceWorker";
import router from "./router";
import store from "./store";
import "./assets/scss/main.scss";
const app = createApp(App);
app.use(store);
app.use(router);
app.mount("#app");

View File

@@ -0,0 +1,32 @@
/* eslint-disable no-console */
import { register } from "register-service-worker";
if (process.env.NODE_ENV === "production") {
register(`${process.env.BASE_URL}service-worker.js`, {
ready() {
console.log(
"App is being served from cache by a service worker.\n" +
"For more details, visit https://goo.gl/AFskqB"
);
},
registered() {
console.log("Service worker has been registered.");
},
cached() {
console.log("Content has been cached for offline use.");
},
updatefound() {
console.log("New content is downloading.");
},
updated() {
console.log("New content is available; please refresh.");
},
offline() {
console.log("No internet connection found. App is running in offline mode.");
},
error(error) {
console.error("Error during service worker registration:", error);
}
});
}

View File

@@ -0,0 +1,13 @@
function authenticationGuard(app, to, from, next) {
if (to.authenticationRequired) {
if (app.$store.getters.identity.isAuthenticated) {
next();
} else {
app.$store.dispatch("beginAuthentication");
}
} else {
next();
}
}
export { authenticationGuard };

View File

@@ -0,0 +1,11 @@
import { createRouter, createWebHistory } from "vue-router";
import { authenticationGuard } from "./guards";
import { routes } from "./routes";
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes
});
router.beforeEach((to, from, next) => authenticationGuard(router, to, from, next));
export default router;

View File

@@ -0,0 +1,25 @@
import Home from "../views/Home.vue";
import Authentication from "../views/Authentication.vue";
const routes = [
{
path: "/",
name: "Home",
component: Home
},
{
path: "/about",
name: "About",
// route level code-splitting
// this generates a separate chunk (about.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import(/* webpackChunkName: "about" */ "../views/About.vue")
},
{
path: "/authentication/:action?",
name: "Authentication",
component: Authentication,
props: true,
}
];
export { routes };

View File

@@ -0,0 +1,13 @@
import { UserManager, WebStorageStateStore } from "oidc-client";
const userManager = new UserManager({
authority: window.location.origin,
client_id: "MultiShop",
redirect_uri: window.location.origin + "/authentication/login-callback",
post_logout_redirect_uri: window.location.origin + "/authentication/logout-callback",
response_type: "code",
scope: "openid profile",
userStore: new WebStorageStateStore({ store: window.localStorage }),
});
export { userManager };

View File

@@ -0,0 +1,23 @@
import axios from "axios";
let currentAuthorizationInterceptorID = null;
const http = axios.create({
baseURL: window.location.origin + "/api",
timeout: 2000,
});
function addBearerTokenInterceptor(token) {
currentAuthorizationInterceptorID = http.interceptors.request.use((config) => {
config.headers.Authorization = `Bearer ${token}`;
return config;
}, (err) => {
console.error(err);
});
}
function removeBearerTokenInterceptor() {
http.interceptors.request.eject(currentAuthorizationInterceptorID);
}
export { http, addBearerTokenInterceptor, removeBearerTokenInterceptor };

View File

@@ -0,0 +1,31 @@
const prefix = "MultiShop";
function put(key, value) {
if (value == null) return false;
try {
localStorage.setItem(prefix + ":" + key, JSON.stringify(value));
} catch (error) {
console.error(error);
return false;
}
return true;
}
function get(key) {
if (!exists(key)) return null;
try {
return JSON.parse(localStorage.getItem(prefix + ":" + key));
} catch (error) {
console.error(error);
return null;
}
}
function remove(key) {
localStorage.removeItem(prefix + ":" + key);
}
function exists(key) {
return localStorage.getItem(prefix + ":" + key) != null;
}
export { put, get, remove, exists };

View File

@@ -0,0 +1,99 @@
import router from "../router";
import { userManager } from "../services/authentication";
import { addBearerTokenInterceptor, removeBearerTokenInterceptor } from "../services/http";
import { get, put } from "../services/persistence";
const identity = {
state: () => ({
user: null,
loading: true,
callbackPath: null,
}),
getters: {
isAuthenticated(state) {
return state.user ? !state.user.expired : false;
},
username(state) {
return (state.user && state.user.profile.name) ? state.user.profile.name : null;
},
authCallbackLocation(state) {
return state.callbackPath ? state.callbackPath : get("callbackPath");
}
},
mutations: {
login(state, { user }) {
if (!user) return;
state.user = user;
addBearerTokenInterceptor(user.access_token);
},
logout(state) {
state.user = null;
removeBearerTokenInterceptor();
},
beginAuthenticating(state) {
state.loading = true;
},
endAuthenticating(state) {
state.loading = false;
},
authCallbackLocation(state) {
state.callbackPath = window.location.pathname;
put("callbackPath", state.callbackPath);
}
},
actions: {
async loadUser(context) {
context.commit("beginAuthenticating");
const user = await userManager.getUser();
if (user != null) {
context.commit("login", { user });
}
context.commit("endAuthenticating");
},
async attemptSilentAuthentication(context) {
if (context.getters.isAuthenticated) return;
context.commit("beginAuthenticating");
context.dispatch("loadUser");
if (!context.getters.isAuthenticated) {
try {
const user = await userManager.signinSilent({
redirect_uri: window.location.origin + "/authentication/silent-login-callback"
});
context.commit("login", { user });
} catch { }
context.commit("endAuthenticating");
}
},
async beginAuthentication(context) {
if (context.getters.isAuthenticated) return;
context.commit("beginAuthenticating");
context.dispatch("loadUser");
if (!context.getters.isAuthenticated) {
context.commit("authCallbackLocation");
userManager.signinRedirect();
}
},
async beginDeauthentication(context) {
if (!context.getters.isAuthenticated) return;
context.commit("beginAuthenticating");
context.commit("authCallbackLocation");
await userManager.removeUser();
userManager.signoutRedirect();
},
async completeAuthentication(context, { user }) {
if (!user) return;
context.commit("login", { user });
router.push(context.getters.authCallbackLocation);
context.commit("endAuthenticating");
},
async completeDeauthentication(context) {
if (!context.getters.isAuthenticated) return;
context.commit("logout");
router.push(context.getters.authCallbackLocation);
context.commit("endAuthenticating");
}
},
};
export { identity };

View File

@@ -0,0 +1,16 @@
import { createStore } from "vuex";
import { identity } from "./identity";
export default createStore({
state: {
},
getters: {
},
mutations: {
},
actions: {
},
modules: {
identity: identity,
},
});

View File

@@ -0,0 +1,5 @@
<template>
<div class="about">
<h1>This is an about page</h1>
</div>
</template>

View File

@@ -0,0 +1,68 @@
<template>
<div class="info">
<WaitCircle class="text-primary">
<span class="my-1 text-muted"
>Hang on just a sec! Waiting for authentication stuff to
finish.</span
>
</WaitCircle>
<div class="my-1 text-muted" v-show="longTimePassed">
This seems to be taking longer than expected...
<router-link to="/">Return home</router-link>?
</div>
</div>
</template>
<script>
import WaitCircle from "@/components/WaitCircle.vue";
import { userManager } from "../services/authentication";
export default {
name: "Authentication",
props: {
action: String
},
components: {
WaitCircle
},
data() {
return {
longTimePassed: false
};
},
mounted() {
setTimeout(() => (this.longTimePassed = true), 5000);
this.completeCallback();
},
methods: {
async completeCallback() {
if (this.action === "login-callback") {
const user = await userManager.signinRedirectCallback();
this.$store.dispatch("completeAuthentication", { user });
} else if (this.action === "silent-login-callback") {
await userManager.signinSilentCallback();
} else if (this.action === "logout-callback") {
await userManager.signoutRedirectCallback();
this.$store.dispatch("completeDeauthentication");
} else {
console.warn("Unknown callback: " + this.action);
}
}
},
watch: {
async action() {
this.completeCallback();
}
}
};
</script>
<style lang="scss" scoped>
.info {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
}
</style>

View File

@@ -0,0 +1,13 @@
<template>
<div class="home">
<img alt="Vue logo" src="../assets/images/logo.png">
</div>
</template>
<script>
export default {
name: "Home",
components: {
}
};
</script>