This commit is contained in:
j3d1 2023-12-09 00:57:09 +01:00
parent 55577adde8
commit 916de9fd6b
7 changed files with 498 additions and 34 deletions

78
TODO.md Normal file
View file

@ -0,0 +1,78 @@
# Issues
* [ ] Frontend to add, edit and delete events
* [ ] Backend to add, edit and delete events
* [ ] api testcases for events
* [ ] Frontend to add, edit and delete users
* [ ] Backend to add, edit and delete users
* [ ] api testcases for users
* [ ] check permissions in all api endpoints
* [ ] tickets
* [ ] Frontend to add, edit and delete tickets
* [ ] Backend to add, edit and delete tickets
* [ ] api testcases for tickets
* [ ] Frontend: change ticket status
* [ ] Backend: change ticket status
* [ ] api testcases for ticket status
* [ ] Frontend: assign tickets to users
* [ ] Backend: assign tickets to users
* [ ] api testcases for ticket assignment
* [ ] Frontend: ticket search
* [ ] Backend: ticket search
* [ ] api testcases for ticket search
* [ ] Frontend: ticket comments
* [ ] Backend: ticket comments
* [ ] api testcases for ticket comments
* [ ] Frontend: send replay mails
* [ ] Backend: send replay mails
* [ ] api testcases for replay mails
* [ ] Frontend: manage auto mail triggers
* [ ] Backend: manage auto mail triggers
* [ ] api testcases for auto mail triggers
* [ ] Frontend: manage mail templates
* [ ] Backend: manage mail templates
* [ ] api testcases for mail templates
* [ ] Backend: send notification mails to users
* [ ] testcases for notification mails
* [ ] Frontend: notification settings
* [ ] Backend: notification settings
* [ ] api testcases for notification settings
* [ ] Backend: Telegram bot
* [ ] Backend: route mail to tickets bases on +tag
* [ ] testcases for mail to tickets
* [ ] Frontend: login, logout, register
* [ ] Backend: login, logout, register
* [ ] api testcases for login, logout, register
* [ ] Frontend: item search
* [ ] Backend: item search
* [ ] api testcases for item search
* [ ] Frontend: to math items to tickets
* [ ] Backend: to math items to tickets
* [ ] api testcases for item to tickets
* [ ] Frontend: to show item history
* [ ] Backend: to show item history
* [ ] api testcases for item history
* [ ] Frontend: to delegate permissions via qr code
* [ ] testcases for qr code
* [ ] Frontend to add, edit and delete boxes
* [ ] Backend to add, edit and delete boxes
* [ ] api testcases for boxes
* [ ] Frontend: to show box history
* [ ] Backend: to show box history
* [ ] api testcases for box history
* [ ] Frontend: clear, disband and move boxes
* [ ] Backend: clear, disband and move boxes
* [ ] api testcases for clear, disband and move boxes
* [ ] testcases for receiving mails and auto reply
* [ ] Frontend: merging tickets
* [ ] Backend: merging tickets
* [ ] api testcases for merging tickets
* [ ] concept: create items from "found something" tickets
* [ ] concept: purge old tickets
* [ ] concept: purge old items
* [ ] concept: auto email stale after x days
## Priority: TODO
* send mails from web frontend
* login / user management

View file

@ -1,4 +1,7 @@
from rest_framework import routers, viewsets, serializers from rest_framework import routers, viewsets, serializers
from rest_framework.decorators import api_view, permission_classes, authentication_classes
from rest_framework.response import Response
from rest_framework.authentication import BasicAuthentication
from django.contrib.auth.models import User from django.contrib.auth.models import User
@ -8,13 +11,42 @@ class UserSerializer(serializers.ModelSerializer):
fields = ('id', 'username', 'email', 'first_name', 'last_name') fields = ('id', 'username', 'email', 'first_name', 'last_name')
class RegisterUserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ('username', 'password', 'email')
extra_kwargs = {
'password': {'write_only': True},
}
class UserViewSet(viewsets.ModelViewSet): class UserViewSet(viewsets.ModelViewSet):
queryset = User.objects.all() queryset = User.objects.all()
serializer_class = UserSerializer serializer_class = UserSerializer
authentication_classes = [] authentication_classes = [BasicAuthentication]
permission_classes = [] permission_classes = []
@api_view(['GET'])
@permission_classes([])
@authentication_classes([BasicAuthentication])
def token(request):
return Response({
'token': request.user.auth_token.key
})
@api_view(['POST'])
@permission_classes([])
@authentication_classes([])
def registerUser(request):
serializer = RegisterUserSerializer(data=request.data)
if serializer.is_valid():
user = serializer.save()
return Response({'username': user.username, 'email': user.email}, status=201)
return Response(serializer.errors, status=400)
router = routers.SimpleRouter() router = routers.SimpleRouter()
router.register(r'users', UserViewSet, basename='users') router.register(r'users', UserViewSet, basename='users')

View file

@ -1,9 +1,9 @@
<template> <template>
<div id="app"> <div id="app">
<AddItemModal v-if="addModalOpen" @close="closeAddModal()" isModal="true"/> <AddItemModal v-if="addModalOpen && isLoggedIn" @close="closeAddModal()" isModal="true"/>
<Navbar @addClicked="openAddModal()"/> <Navbar v-if="isLoggedIn" @addClicked="openAddModal()"/>
<router-view/> <router-view/>
<div aria-live="polite" aria-atomic="true" <div aria-live="polite" aria-atomic="true" v-if="isLoggedIn"
class="d-flex justify-content-end align-items-start fixed-top mx-1 my-5 py-3" class="d-flex justify-content-end align-items-start fixed-top mx-1 my-5 py-3"
style="min-height: 200px; z-index: 100000; pointer-events: none"> style="min-height: 200px; z-index: 100000; pointer-events: none">
<Toast v-for="toast in toasts" :key="toast" :title="toast.title" :message="toast.message" <Toast v-for="toast in toasts" :key="toast" :title="toast.title" :message="toast.message"
@ -17,12 +17,15 @@
import Navbar from '@/components/Navbar'; import Navbar from '@/components/Navbar';
import AddItemModal from '@/components/AddItemModal'; import AddItemModal from '@/components/AddItemModal';
import Toast from './components/Toast'; import Toast from './components/Toast';
import {mapState, mapMutations, mapActions} from 'vuex'; import {mapState, mapMutations, mapActions, mapGetters} from 'vuex';
export default { export default {
name: 'app', name: 'app',
components: {Toast, Navbar, AddItemModal}, components: {Toast, Navbar, AddItemModal},
computed: mapState(['loadedItems', 'layout', 'toasts']), computed: {
...mapState(['loadedItems', 'layout', 'toasts']),
...mapGetters(['isLoggedIn']),
},
data: () => ({ data: () => ({
addModalOpen: false, addModalOpen: false,
notify_socket: null, notify_socket: null,
@ -55,6 +58,7 @@ export default {
this.removeToast(this.socket_toast.key); this.removeToast(this.socket_toast.key);
this.socket_toast = null; this.socket_toast = null;
} }
console.log(e);
this.socket_toast = this.createToast({ this.socket_toast = this.createToast({
title: "Connection established", title: "Connection established",
message: JSON.stringify(e), message: JSON.stringify(e),
@ -66,6 +70,7 @@ export default {
this.removeToast(this.socket_toast.key); this.removeToast(this.socket_toast.key);
this.socket_toast = null; this.socket_toast = null;
} }
console.log(e);
this.socket_toast = this.createToast({ this.socket_toast = this.createToast({
title: "Connection closed", title: "Connection closed",
message: JSON.stringify(e), message: JSON.stringify(e),
@ -80,6 +85,7 @@ export default {
this.removeToast(this.socket_toast.key); this.removeToast(this.socket_toast.key);
this.socket_toast = null; this.socket_toast = null;
} }
console.log(e);
this.socket_toast = this.createToast({ this.socket_toast = this.createToast({
title: "Connection error", title: "Connection error",
message: JSON.stringify(e), message: JSON.stringify(e),
@ -93,8 +99,6 @@ export default {
let data = JSON.parse(e.data); let data = JSON.parse(e.data);
//this.loadItems() //this.loadItems()
this.loadTickets() this.loadTickets()
} }
} }
}, },

View file

@ -6,6 +6,8 @@ import Error from './views/Error';
import HowTo from './views/HowTo'; import HowTo from './views/HowTo';
import VueRouter from 'vue-router'; import VueRouter from 'vue-router';
import Vue from 'vue'; import Vue from 'vue';
import Login from '@/views/Login.vue';
import Register from '@/views/Register.vue';
import Debug from "@/views/admin/Debug.vue"; import Debug from "@/views/admin/Debug.vue";
import Tickets from "@/views/Tickets.vue"; import Tickets from "@/views/Tickets.vue";
import Ticket from "@/views/Ticket.vue"; import Ticket from "@/views/Ticket.vue";
@ -15,19 +17,21 @@ import store from "@/store";
Vue.use(VueRouter); Vue.use(VueRouter);
const routes = [ const routes = [
{path: '/', redirect: '/Camp23/items'}, {path: '/', redirect: '/Camp23/items', meta: {requiresAuth: false}},
{path: '/howto', name: 'howto', component: HowTo}, {path: '/login', name: 'login', component: Login, meta: {requiresAuth: false}},
{path: '/:event/boxes', name: 'boxes', component: Boxes}, {path: '/register', name: 'register', component: Register, meta: {requiresAuth: false}},
{path: '/:event/items', name: 'items', component: Items}, {path: '/howto', name: 'howto', component: HowTo, meta: {requiresAuth: true}},
{path: '/:event/box/:uid', name: 'box', component: Boxes}, {path: '/:event/boxes', name: 'boxes', component: Boxes, meta: {requiresAuth: true}},
{path: '/:event/item/:uid', name: 'item', component: Items}, {path: '/:event/items', name: 'items', component: Items, meta: {requiresAuth: true}},
{path: '/:event/tickets', name: 'tickets', component: Tickets}, {path: '/:event/box/:uid', name: 'box', component: Boxes, meta: {requiresAuth: true}},
{path: '/:event/ticket/:id', name: 'ticket', component: Ticket}, {path: '/:event/item/:uid', name: 'item', component: Items, meta: {requiresAuth: true}},
{path: '/admin', name: 'admin', component: Admin}, {path: '/:event/tickets', name: 'tickets', component: Tickets, meta: {requiresAuth: true}},
{path: '/admin/files', name: 'files', component: Files}, {path: '/:event/ticket/:id', name: 'ticket', component: Ticket, meta: {requiresAuth: true}},
{path: '/admin/events', name: 'events', component: Events}, {path: '/admin', name: 'admin', component: Admin, meta: {requiresAuth: true}},
{path: '/admin/debug', name: 'debug', component: Debug}, {path: '/admin/files', name: 'files', component: Files, meta: {requiresAuth: true}},
{path: '/admin/users', name: 'users', component: Events}, {path: '/admin/events', name: 'events', component: Events, meta: {requiresAuth: true}},
{path: '/admin/debug', name: 'debug', component: Debug, meta: {requiresAuth: true}},
{path: '/admin/users', name: 'users', component: Events, meta: {requiresAuth: true}},
{path: '*', component: Error}, {path: '*', component: Error},
]; ];
@ -36,7 +40,34 @@ const router = new VueRouter({
routes, routes,
}); });
router.afterEach((to, from) => { //router.beforeEach((to/*, from*/, next) => {
// console.log("beforeEach", to);
// if (to.meta.requiresAuth && !store.getters.isLoggedIn) {
// console.log("Not logged in, redirecting to login page")
// return {
// name: 'login',
// query: {redirect: to.fullPath},
// }
// }
//});
//router.beforeResolve((to, from, next) => {
// next()
//});
router.beforeEach((to, from, next) => {
if (to.meta.requiresAuth && !store.getters.isLoggedIn) {
//console.log("Not logged in, redirecting to login page")
next({
name: 'login',
query: {redirect: to.fullPath},
})
} else {
next()
}
});
router.afterEach((to/*, from*/) => {
if (to.params.event) { if (to.params.event) {
//console.log('update last event', to.params.event); //console.log('update last event', to.params.event);
store.commit('updateLastEvent', to.params.event); store.commit('updateLastEvent', to.params.event);

View file

@ -47,6 +47,10 @@ const store = new Vuex.Store({
userRoles: ['admin', 'team', 'orga', 'user'], userRoles: ['admin', 'team', 'orga', 'user'],
lastEvent: localStorage.getItem('lf_lastEvent') || '36C3', lastEvent: localStorage.getItem('lf_lastEvent') || '36C3',
lastUsed: localStorage.getItem('lf_lastUsed') || {}, lastUsed: localStorage.getItem('lf_lastUsed') || {},
remember: false,
user: null,
token: null,
local_loaded: false,
}, },
getters: { getters: {
getEventSlug: state => state.route && state.route.params.event ? state.route.params.event : state.lastEvent, getEventSlug: state => state.route && state.route.params.event ? state.route.params.event : state.lastEvent,
@ -54,6 +58,16 @@ const store = new Vuex.Store({
getFilters: state => state.route.query, getFilters: state => state.route.query,
getBoxes: state => state.loadedBoxes, getBoxes: state => state.loadedBoxes,
checkRole: state => role => state.userRoles.includes(role), checkRole: state => role => state.userRoles.includes(role),
isLoggedIn(state) {
if (!state.local_loaded) {
state.remember = localStorage.getItem('remember') === 'true'
state.user = localStorage.getItem('user')
state.token = localStorage.getItem('token')
state.local_loaded = true
}
return state.user !== null && state.token !== null;
},
}, },
mutations: { mutations: {
updateLastUsed(state, diff) { updateLastUsed(state, diff) {
@ -104,53 +118,84 @@ const store = new Vuex.Store({
}, },
removeToast(state, key) { removeToast(state, key) {
state.toasts = state.toasts.filter(toast => toast.key !== key); state.toasts = state.toasts.filter(toast => toast.key !== key);
} },
setRemember(state, remember) {
state.remember = remember;
localStorage.setItem('remember', remember);
},
setUser(state, user) {
state.user = user;
localStorage.setItem('user', user);
},
logout(state) {
state.user = null;
state.token = null;
localStorage.removeItem('user');
localStorage.removeItem('token');
router.push('/login');
},
}, },
actions: { actions: {
async login({commit, dispatch, state}, {username, password, remember}) {
commit('setRemember', remember);
const data = await fetch('/api/2/auth/token/', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({username: username, password: password}),
credentials: 'omit'
}).then(r => r.json())
if (data.token && data.key) {
commit('setToken', data.token);
commit('setUser', username);
return true;
} else {
return false;
}
},
async loadEvents({commit}) { async loadEvents({commit}) {
const {data} = await axios.get('/1/events'); const {data} = await axios.get('/1/events/');
commit('replaceEvents', data); commit('replaceEvents', data);
}, },
changeEvent({dispatch, getters}, eventName) { changeEvent({dispatch, getters}, eventName) {
router.push({path: `/${eventName.slug}/${getters.getActiveView}`}); router.push({path: `/${eventName.slug}/${getters.getActiveView}/`});
dispatch('loadEventItems'); dispatch('loadEventItems');
}, },
changeView({getters}, link) { changeView({getters}, link) {
router.push({path: `/${getters.getEventSlug}/${link.path}`}); router.push({path: `/${getters.getEventSlug}/${link.path}/`});
}, },
showBoxContent({getters}, box) { showBoxContent({getters}, box) {
router.push({path: `/${getters.getEventSlug}/items`, query: {box}}); router.push({path: `/${getters.getEventSlug}/items/`, query: {box}});
}, },
async loadEventItems({commit, getters}) { async loadEventItems({commit, getters}) {
const {data} = await axios.get(`/1/${getters.getEventSlug}/items`); const {data} = await axios.get(`/2/${getters.getEventSlug}/items/`);
commit('replaceLoadedItems', data); commit('replaceLoadedItems', data);
}, },
async searchEventItems({commit, getters}, query) { async searchEventItems({commit, getters}, query) {
const foo = utf8.encode(query); const foo = utf8.encode(query);
const bar = base64.encode(foo); const bar = base64.encode(foo);
const {data} = await axios.get(`/1/${getters.getEventSlug}/items/${bar}`); const {data} = await axios.get(`/2/${getters.getEventSlug}/items/${bar}/`);
commit('replaceLoadedItems', data); commit('replaceLoadedItems', data);
}, },
async loadBoxes({commit}) { async loadBoxes({commit}) {
const {data} = await axios.get('/1/boxes'); const {data} = await axios.get('/2/boxes/');
commit('replaceBoxes', data); commit('replaceBoxes', data);
}, },
async updateItem({commit, getters}, item) { async updateItem({commit, getters}, item) {
const {data} = await axios.put(`/1/${getters.getEventSlug}/item/${item.uid}`, item); const {data} = await axios.put(`/2/${getters.getEventSlug}/item/${item.uid}/`, item);
commit('updateItem', data); commit('updateItem', data);
}, },
async markItemReturned({commit, getters}, item) { async markItemReturned({commit, getters}, item) {
await axios.put(`/1/${getters.getEventSlug}/item/${item.uid}`, {returned: true}); await axios.put(`/2/${getters.getEventSlug}/item/${item.uid}/`, {returned: true});
commit('removeItem', item); commit('removeItem', item);
}, },
async deleteItem({commit, getters}, item) { async deleteItem({commit, getters}, item) {
await axios.delete(`/1/${getters.getEventSlug}/item/${item.uid}`, item); await axios.delete(`/2/${getters.getEventSlug}/item/${item.uid}/`, item);
commit('removeItem', item); commit('removeItem', item);
}, },
async postItem({commit, getters}, item) { async postItem({commit, getters}, item) {
commit('updateLastUsed', {box: item.box, cid: item.cid}); commit('updateLastUsed', {box: item.box, cid: item.cid});
const {data} = await axios.post(`/1/${getters.getEventSlug}/item`, item); const {data} = await axios.post(`/2/${getters.getEventSlug}/item/`, item);
commit('appendItem', data); commit('appendItem', data);
}, },
async loadTickets({commit}) { async loadTickets({commit}) {

120
web/src/views/Login.vue Normal file
View file

@ -0,0 +1,120 @@
<template>
<main class="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">
C3LF System3
</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 bg-dark">
<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" 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" 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-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>
</main>
</template>
<script>
import {mapActions, mapMutations} from 'vuex';
import router from "@/router";
export default {
name: 'Login',
data() {
return {
msg: 'Welcome to ' + window.location.hostname,
username: '',
password: '',
remember: false
}
},
methods: {
...mapActions(['login']),
...mapMutations(['setRemember']),
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>
input{
background-color: var(--dark);
border: var(--gray) 1px solid;;
&:focus {
background-color: var(--dark);
}
&:hover {
background-color: var(--dark);
}
&[type="checkbox"] {
background-color: var(--dark);
}
}
</style>

154
web/src/views/Register.vue Normal file
View file

@ -0,0 +1,154 @@
<template>
<main class="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">
C3LF System3
</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 bg-dark">
<div class="card-body">
<div class="m-sm-4">
<form role="form" method="post" @submit.prevent="do_register">
<div :class="errors.username?['mb-3','is-invalid']:['mb-3']">
<label class="form-label">Username</label>
<div class="input-group">
<input class="form-control"
type="text" v-model="form.username" id="validationCustomUsername"
placeholder="Enter your username" required/>
</div>
<div class="invalid-feedback">
{{ errors.username }}
</div>
</div>
<div :class="errors.password?['mb-3','is-invalid']:['mb-3']">
<label class="form-label">Password</label>
<input class="form-control" 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" 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-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>
</main>
</template>
<script>
export default {
name: 'Register',
data() {
return {
msg: 'Register',
password2: '',
form: {
username: '',
password: '',
},
errors: {
username: null,
password: null,
password2: null,
}
}
},
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('/api/2/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() {
}
}
</script>
<style scoped>
.is-invalid input, .is-invalid select {
border: 1px solid var(--danger);
}
.is-invalid .invalid-feedback {
display: block;
}
input{
background-color: var(--dark);
border: var(--gray) 1px solid;;
&:focus {
background-color: var(--dark);
}
&:hover {
background-color: var(--dark);
}
&[type="checkbox"] {
background-color: var(--dark);
}
}
</style>