frontend: add /login and /register forms

This commit is contained in:
j3d1 2024-03-17 18:52:41 +01:00
parent bea56f101a
commit c4c49931a4
13 changed files with 3635 additions and 0 deletions

2807
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

29
frontend/package.json Normal file
View file

@ -0,0 +1,29 @@
{
"name": "frontend",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"bootstrap": "^4.6.2",
"bootstrap-icons-vue": "^1.10.3",
"dns-query": "^0.11.2",
"js-nacl": "^1.4.0",
"moment": "^2.29.4",
"vue": "^3.2.47",
"vue-multiselect": "^2.1.7",
"vue-router": "^4.1.6",
"vuex": "^4.1.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.0.0",
"@vue/test-utils": "^2.3.2",
"jsdom": "^22.0.0",
"sass": "^1.72.0",
"vite": "^4.1.4",
"vitest": "^0.31.1"
}
}

18
frontend/src/App.vue Normal file
View file

@ -0,0 +1,18 @@
<script setup>
</script>
<template>
<router-view></router-view>
</template>
<script>
export default {
name: 'App'
}
</script>
<style scoped>
</style>

View file

@ -0,0 +1,110 @@
<template>
<div class="wrapper">
<div class="main">
<nav class="navbar navbar-expand navbar-light navbar-bg">
<a class="sidebar-toggle d-flex" @click="toggleSidebar">
<i class="hamburger align-self-center"></i>
</a>
</nav>
<slot></slot>
<Footer/>
</div>
</div>
</template>
<script>
import Footer from "@/components/Footer.vue";
export default {
name: 'BaseLayout',
components: {
Footer
},
props: {
hideSearch: {
type: Boolean,
required: false,
default: false
}
},
methods: {
toggleSidebar() {
},
},
}
</script>
<style scoped>
.wrapper {
align-items: stretch;
display: flex;
width: 100%;
}
.main {
display: flex;
width: 100%;
min-width: 0;
min-height: 100vh;
transition: margin-left .35s ease-in-out, left .35s ease-in-out, margin-right .35s ease-in-out, right .35s ease-in-out;
flex-direction: column;
overflow: hidden;
}
.navbar-expand {
flex-wrap: nowrap;
justify-content: flex-start;
}
.navbar {
border-bottom: 0;
padding: .875rem 1.375rem;
box-shadow: 0 0 2rem var(--bs-shadow)
}
.navbar-bg {
background: var(--bs-white);
}
.sidebar-toggle {
cursor: pointer;
width: 26px;
height: 26px;
margin-right: 1rem;
}
.hamburger {
position: relative;
&, &:after, &:before {
cursor: pointer;
border-radius: 1px;
height: 3px;
width: 24px;
background: var(--bs-gray-700);
display: block;
content: "";
transition: background .1s ease-in-out, color .1s ease-in-out
}
&:before {
top: -7.5px;
width: 24px;
position: absolute
}
&:after {
bottom: -7.5px;
width: 16px;
position: absolute
}
}
.sidebar-toggle:hover {
.hamburger, .hamburger:after, .hamburger:before {
background: var(--bs-primary);
}
}
</style>

View file

@ -0,0 +1,53 @@
<template>
<footer class="footer">
<div class="container-fluid">
<div class="row text-muted">
<div class="col-6 text-left">
<p class="mb-0">
<a target="_blank" href="https://www.gnu.org/licenses/gpl-3.0.de.html"
class="text-muted">
License: <strong>GPL-3.0</strong>
</a>
</p>
</div>
<div class="col-6 text-right">
<ul class="list-inline">
<li class="list-inline-item">
<a class="text-muted"
target="_blank" href="/docs/">API Docs</a>
</li>
<li class="list-inline-item">
<a class="text-muted"
target="_blank" href="/wiki/">Wiki</a>
</li>
<li class="list-inline-item">
<a class="text-muted"
target="_blank" href="https://github.com/gr4yj3d1/toolshed">Sources</a>
</li>
</ul>
</div>
</div>
</div>
</footer>
</template>
<script>
export default {
name: "Footer"
}
</script>
<style scoped>
.footer {
padding: 1rem .875rem;
direction: ltr;
background: var(--bs-background-1)
}
.footer ul {
margin-bottom: 0;
}
</style>

16
frontend/src/main.js Normal file
View file

@ -0,0 +1,16 @@
import {createApp} from 'vue'
import {BootstrapIconsPlugin} from 'bootstrap-icons-vue';
import App from './App.vue'
import './scss/toolshed.scss'
import router from './router'
import _nacl from 'js-nacl';
const app = createApp(App).use(BootstrapIconsPlugin);
_nacl.instantiate((nacl) => {
window.nacl = nacl
app.use(router).mount('#app')
});

35
frontend/src/router.js Normal file
View file

@ -0,0 +1,35 @@
import {createRouter, createWebHistory} from 'vue-router'
import Index from '@/views/Index.vue';
import Login from '@/views/Login.vue';
import Register from '@/views/Register.vue';
const routes = [
{path: '/', component: Index, meta: {requiresAuth: true}},
{path: '/login', component: Login, meta: {requiresAuth: false}},
{path: '/register', component: Register, meta: {requiresAuth: false}},
]
const router = createRouter({
// 4. Provide the history implementation to use. We are using the hash history for simplicity here.
history: createWebHistory(),
linkActiveClass: "active",
routes, // short for `routes: routes`
})
router.beforeEach((to/*, from*/) => {
// instead of having to check every route record with
// to.matched.some(record => record.meta.requiresAuth)
if (to.meta.requiresAuth && false) {
// this route requires auth, check if logged in
// if not, redirect to login page.
console.log("Not logged in, redirecting to login page")
return {
path: '/login',
// save the location we were at to come back later
query: {redirect: to.fullPath},
}
}
})
export default router

View file

@ -0,0 +1,58 @@
.card {
margin-bottom: 24px;
box-shadow: 0 0 .875rem map-get($theme-colors, shadow);
background-clip: initial;
border: 0 solid transparent;
}
.card-header {
background-color: map-get($theme-colors, background-1);
border-bottom: 0 solid transparent;
}
.card-title {
color: map-get($theme-colors, text-3);
margin-bottom: .5rem;
}
.card-subtitle {
margin-top: -.25rem;
}
.card-subtitle, .card-text:last-child {
margin-bottom: 0;
}
.card {
& > .dataTables_wrapper .table.dataTable,
& > .table,
& > .table-responsive-lg .table,
& > .table-responsive-md .table,
& > .table-responsive-sm .table,
& > .table-responsive-xl .table,
& > .table-responsive .table {
border-right: 0;
border-bottom: 0;
border-left: 0;
margin-bottom: 0;
& tr:first-child td,
& tr:first-child th {
border-top: 0;
}
& td:last-child,
& th:last-child {
border-right: 0;
padding-right: 1.25rem;
}
& td:first-child,
& th:first-child {
border-left: 0;
padding-left: 1.25rem;
}
}
}

View file

@ -0,0 +1,71 @@
.form-control {
width: 100%;
height: initial;
min-height: calc(1.8125rem + 2px);
padding: .25rem .7rem;
appearance: none;
background-color: initial;
border-radius: .2rem;
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
}
.form-control-lg {
height: initial;
min-height: calc(2.0875rem + 2px);
padding: .35rem 1rem;
font-size: .925rem;
border-radius: .3rem
}
.btn {
display: inline-block;
font-weight: 400;
line-height: 1.5;
text-align: center;
vertical-align: middle;
cursor: pointer;
user-select: none;
padding: .25rem .7rem;
font-size: .875rem;
border-radius: .2rem;
transition: color .15s ease-in-out, background-color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out;
}
.form-select {
width: 100%;
padding: .25rem 1.7rem .25rem .7rem;
color: map-get($theme-colors, text-3);
background-color: map-get($theme-colors, background-1);
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right .7rem center;
background-size: 16px 12px;
border: 1px solid #ced4da;
border-radius: .2rem;
appearance: none;
}
.btn-group-sm > .btn, .btn-sm {
padding: .15rem .5rem;
font-size: .75rem;
border-radius: .1rem;
}
.input-group > :not(:first-child):not(.dropdown-menu) {
margin-left: -1px;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.input-group > .dropdown-toggle:nth-last-child(n+3), .input-group > :not(:last-child):not(.dropdown-toggle):not(.dropdown-menu) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.input-group-text {
display: flex;
align-items: center;
padding: .25rem .7rem;
background-color: map-get($theme-colors, background-2);
border-right-width: 0;
}

View file

@ -0,0 +1,138 @@
$variable-prefix: bs-;
$white: #ffffff;
$theme-colors: (
"light": #d7e1dc,
"dark": #1f2327,
"primary": #3a7ddd,
"secondary": #45393a,
"info": #027980,
"success": #019a56,
"warning": #ffc107,
"danger": #ee1200,
"background-1": $white,
"background-2": #e9ecef,
"text-1": #000,
"text-3": #495057,
"shadow": #2125291a,
);
$font-size-base: 0.875rem;
$h1-font-size: $font-size-base * 2;
$h2-font-size: $font-size-base * 1.75;
$h3-font-size: $font-size-base * 1.5;
$h4-font-size: $font-size-base * 1.25;
$h5-font-size: $font-size-base;
$h6-font-size: $font-size-base;
@import "bootstrap/scss/functions";
@import "bootstrap/scss/variables";
$body-color: $gray-700;
@import "bootstrap/scss/mixins";
:root {
@each $color, $value in $colors {
--#{$variable-prefix}#{$color}: #{$value};
}
@each $color, $value in $theme-colors {
--#{$variable-prefix}#{$color}: #{$value};
}
@each $color, $value in $grays {
--#{$variable-prefix}gray-#{$color}: #{$value};
}
@each $bp, $value in $grid-breakpoints {
--#{$variable-prefix}breakpoint-#{$bp}: #{$value};
}
--#{$variable-prefix}font-family-sans-serif: #{inspect($font-family-sans-serif)};
--#{$variable-prefix}font-family-monospace: #{inspect($font-family-monospace)};
}
@import "bootstrap/scss/reboot";
@import "bootstrap/scss/type";
@import "bootstrap/scss/images";
@import "bootstrap/scss/code";
@import "bootstrap/scss/grid";
@import "bootstrap/scss/tables";
@import "bootstrap/scss/forms";
@import "bootstrap/scss/buttons";
@import "bootstrap/scss/transitions";
@import "bootstrap/scss/dropdown";
@import "bootstrap/scss/button-group";
@import "bootstrap/scss/input-group";
@import "bootstrap/scss/custom-forms";
@import "bootstrap/scss/nav";
@import "bootstrap/scss/navbar";
@import "bootstrap/scss/card";
@import "bootstrap/scss/breadcrumb";
@import "bootstrap/scss/pagination";
@import "bootstrap/scss/badge";
@import "bootstrap/scss/jumbotron";
@import "bootstrap/scss/alert";
@import "bootstrap/scss/progress";
@import "bootstrap/scss/media";
@import "bootstrap/scss/list-group";
@import "bootstrap/scss/close";
@import "bootstrap/scss/toasts";
@import "bootstrap/scss/modal";
@import "bootstrap/scss/tooltip";
@import "bootstrap/scss/popover";
@import "bootstrap/scss/carousel";
@import "bootstrap/scss/spinners";
@import "bootstrap/scss/utilities";
@import "bootstrap/scss/print";
@import "card";
@import "forms";
#root, body, html {
height: 100%;
}
body {
overflow-y: scroll;
opacity: 1 !important;
}
.main {
background-color: var(--bs-gray-300);
}
.content {
padding: 1.5rem 1.5rem .75rem;
flex: 1;
width: 100vw;
max-width: 100vw;
direction: ltr
}
@media (min-width: map-get($grid-breakpoints, md)) {
.content {
width: auto;
max-width: auto
}
}
@media (min-width: map-get($grid-breakpoints, lg)) {
.content {
padding: 2.5rem 2.5rem 1rem
}
}
.h1, .h2, .h3, .h4, .h5, .h6, h1, h2, h3, h4, h5, h6 {
font-weight: 400;
color: map-get($theme-colors, text-1);
}
.table > :not(caption) > * > * {
padding: .75rem;
background-color: var(--bs-table-bg);
background-image: linear-gradient(var(--bs-table-accent-bg), var(--bs-table-accent-bg));
border-bottom-width: 1px !important;
}

View file

@ -0,0 +1,31 @@
<template>
<BaseLayout>
<main class="content">
<div class="container-fluid p-0">
<h1 class="h3 mb-3">Dashboard</h1>
<div class="row">
<div class="col-12">
</div>
</div>
</div>
</main>
</BaseLayout>
</template>
<script>
import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
export default {
name: 'Index',
components: {
...BIcons,
BaseLayout
},
}
</script>
<style scoped>
</style>

View file

@ -0,0 +1,106 @@
<template>
<div class="main d-flex w-100">
<div class="container d-flex flex-column">
<div class="row vh-100">
<div class="col-sm-10 col-md-8 col-lg-6 mx-auto d-table h-100">
<div class="d-table-cell align-middle">
<div class="text-center mt-4">
<h1 class="h2">
Toolshed
</h1>
<p class="lead" v-if="msg">
{{ msg }}
</p>
<p class="lead" v-else>
Sign in to your account to continue
</p>
</div>
<div class="card">
<div class="card-body">
<div class="m-sm-4">
<form role="form" @submit.prevent="do_login">
<div class="mb-3">
<label class="form-label">Username</label>
<input class="form-control form-control-lg" type="text"
name="username" placeholder="Enter your username"
v-model="username"/>
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input class="form-control form-control-lg" type="password"
name="password" placeholder="Enter your password"
v-model="password"/>
</div>
<div>
<label class="form-check">
<input class="form-check-input" type="checkbox" value="remember-me"
name="remember-me" checked v-model="remember"
@change="setRemember(remember)">
<span class="form-check-label">
Remember me next time
</span>
</label>
</div>
<div class="text-center mt-3">
<button type="submit" name="login" class="btn btn-lg btn-primary">Login
</button>
</div>
</form>
<br/>
<div class="text-center">
<p class="mb-0 text-muted">
Dont have an account?
<router-link to="/register">Sign up</router-link>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import router from "@/router";
export default {
name: 'Login',
data() {
return {
msg: 'Welcome to ' + location.hostname,
username: '',
password: '',
remember: false
}
},
methods: {
setRemember(remember) {
},
login(data) {
return true;
},
async do_login(e) {
e.preventDefault();
if (await this.login({username: this.username, password: this.password, remember: this.remember})) {
if (this.$route.query.redirect) {
await router.push({path: this.$route.query.redirect});
} else {
await router.push({path: '/'});
}
} else {
this.msg = 'Invalid username or password';
}
},
}
}
</script>
<style scoped>
</style>

View file

@ -0,0 +1,163 @@
<template>
<div class="main d-flex w-100">
<div class="container d-flex flex-column">
<div class="row vh-100">
<div class="col-sm-10 col-md-8 col-lg-6 mx-auto d-table h-100">
<div class="d-table-cell align-middle">
<div class="text-center mt-4">
<h1 class="h2">
Toolshed
</h1>
<p class="lead" v-if="msg">
{{ msg }}
</p>
<p class="lead" v-else>
Create an account to get started
</p>
</div>
<div class="card">
<div class="card-body">
<div class="m-sm-4">
<form role="form" method="post" @submit.prevent="do_register">
<div :class="errors.username||errors.domain?['mb-3','is-invalid']:['mb-3']">
<label class="form-label">Username</label>
<div class="input-group">
<input class="form-control form-control-lg"
type="text" v-model="form.username" id="validationCustomUsername"
placeholder="Enter your username" required/>
<div class="input-group-prepend">
<span class="input-group-text form-control form-control-lg">@</span>
</div>
<select class="form-control form-control-lg"
id="exampleFormControlSelect1"
placeholder="Domain" v-model="form.domain" required>
<option v-for="domain in domains">{{ domain }}</option>
</select>
</div>
<div class="invalid-feedback">
{{ errors.username }}{{ errors.domain }}
</div>
</div>
<div :class="errors.email?['mb-3','is-invalid']:['mb-3']">
<label class="form-label">Email</label>
<input class="form-control form-control-lg" type="email"
v-model="form.email" placeholder="Enter your email"/>
<div class="invalid-feedback">{{ errors.email }}</div>
</div>
<div :class="errors.password?['mb-3','is-invalid']:['mb-3']">
<label class="form-label">Password</label>
<input class="form-control form-control-lg" type="password"
v-model="form.password" placeholder="Enter your password"/>
<div class="invalid-feedback">{{ errors.password }}</div>
</div>
<div :class="errors.password2?['mb-3','is-invalid']:['mb-3']">
<label class="form-label">Password Check</label>
<input class="form-control form-control-lg" type="password"
v-model="password2" placeholder="Enter your password again"/>
<div class="invalid-feedback">{{ errors.password2 }}</div>
</div>
<div class="text-center mt-3">
<button type="submit" class="btn btn-lg btn-primary">
Register
</button>
</div>
</form>
<br/>
<div class="text-center">
<p class="mb-0 text-muted">
Already have an account?
<router-link to="/login">Login</router-link>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'Register',
data() {
return {
msg: 'Register new account',
password2: '',
form: {
username: '',
domain: '',
email: '',
password: '',
},
errors: {
username: null,
domain: null,
email: null,
password: null,
password2: null,
},
domains: []
}
},
methods: {
do_register() {
console.log('do_register');
console.log(this.form);
if (this.form.password !== this.password2) {
this.errors.password2 = 'Passwords do not match';
return;
} else {
this.errors.password2 = null;
}
fetch('/auth/register/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(this.form)
})
.then(response => response.json())
.then(data => {
if (data.errors) {
console.error('Error:', data.errors);
this.errors = data.errors;
return;
}
console.log('Success:', data);
this.msg = 'Success';
this.$router.push('/login');
})
.catch((error) => {
console.error('Error:', error);
this.msg = 'Error';
});
}
},
mounted() {
fetch('/api/domains/')
.then(response => response.json())
.then(data => {
this.domains = data;
});
}
}
</script>
<style scoped>
.is-invalid input, .is-invalid select {
border: 1px solid var(--bs-danger);
}
.is-invalid .invalid-feedback {
display: block;
}
</style>