This commit is contained in:
j3d1 2025-09-24 22:10:55 +02:00
parent 17bfb84a94
commit 2fe8d14c2c
35 changed files with 1976 additions and 2134 deletions

View file

@ -1,5 +1,8 @@
# toolshed # toolshed
## foo
## Development ## Development
``` bash ``` bash

View file

@ -38,6 +38,7 @@ urlpatterns = [
path('api/', include('toolshed.api.inventory')), path('api/', include('toolshed.api.inventory')),
path('api/', include('toolshed.api.info')), path('api/', include('toolshed.api.info')),
path('api/', include('toolshed.api.files')), path('api/', include('toolshed.api.files')),
path('api/', include('toolshed.api.offlinedata')),
path('media/', include('files.media_urls')), path('media/', include('files.media_urls')),
path('docs/', schema_view.with_ui('swagger', cache_timeout=0), name='api-docs'), path('docs/', schema_view.with_ui('swagger', cache_timeout=0), name='api-docs'),
] ]

View file

@ -0,0 +1,63 @@
from django.http import HttpResponse
from django.urls import path
from rest_framework.decorators import api_view, permission_classes, authentication_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from authentication.signature_auth import SignatureAuthentication
def user_data():
import io
import zipfile
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED, False) as zip_file:
for file_name, data in [('1.txt', io.BytesIO(b'111')),
('2.txt', io.BytesIO(b'222'))]:
zip_file.writestr(file_name, data.getvalue())
return zip_buffer.getvalue()
def parse_user_data(data):
import io
import zipfile
with zipfile.ZipFile(io.BytesIO(data), "r") as zip_file:
for file_name in zip_file.namelist():
yield file_name, zip_file.read(file_name)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthentication])
def import_data(request, format=None):
zip = request.data.get('zip')
if not zip:
return Response(status=400)
for file_name, data in parse_user_data(zip):
print(file_name, data)
return Response(status=200)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthentication])
def export_data(request, format=None):
return HttpResponse(user_data(), content_type='application/zip', status=200)
@api_view(['DELETE'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthentication])
def delete_account(request, format=None):
pass
urlpatterns = [
path('export/', export_data, name='export_data'),
path('import/', import_data, name='import_data'),
path('account_data/', delete_account, name='delete_account'),
]

View file

@ -8,6 +8,7 @@ WORKDIR /app
# A wildcard is used to ensure both package.json AND package-lock.json are copied # A wildcard is used to ensure both package.json AND package-lock.json are copied
COPY package.json ./ COPY package.json ./
COPY extras/ ./extras/
RUN npm install RUN npm install
CMD [ "npm", "run", "dev", "--", "--host"] CMD [ "npm", "run", "dev", "--", "--host"]

View file

@ -1,3 +1,19 @@
[ [
[
{
"speed": 1,
"position": 1
},
{
"speed": 1,
"position": 1
},
{
"speed": 1,
"position": 1
}
]
arr[1].speed
"a.localhost" "a.localhost"
] ]

1
frontend/extras Submodule

@ -0,0 +1 @@
Subproject commit 21bf28a8b6293872f5f7a42c9ca0bc12ede5aaae

0
frontend/node_modules/.forgit generated vendored
View file

File diff suppressed because it is too large Load diff

View file

@ -16,7 +16,10 @@
"vue": "^3.2.47", "vue": "^3.2.47",
"vue-multiselect": "^2.1.7", "vue-multiselect": "^2.1.7",
"vue-router": "^4.1.6", "vue-router": "^4.1.6",
"vuex": "^4.1.0" "vuex": "^4.1.0",
"extras": "file:./extras",
"jquery": "^3.6.0",
"popper.js": "^1.16.1"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^4.0.0", "@vitejs/plugin-vue": "^4.0.0",

View file

@ -3,10 +3,10 @@
<h3 v-if="create">Create New</h3> <h3 v-if="create">Create New</h3>
<h3 v-else>Update</h3> <h3 v-else>Update</h3>
<ul> <ul>
<li v-for="file in whithout_images(files)" :key="file.id"> <li v-for="file in without_images(files)" :key="file.id">
{{ file.name }} {{ file.name }}
</li> </li>
<li v-for="file in whithout_images(item_files)" :key="file.id"> <li v-for="file in without_images(item_files)" :key="file.id">
{{ file.name }} {{ file.name }}
</li> </li>
</ul> </ul>
@ -100,23 +100,26 @@
<script> <script>
import * as BIcons from "bootstrap-icons-vue"; import * as BIcons from "bootstrap-icons-vue";
import {mapActions, mapState} from "vuex"; import {mapActions, mapState} from "vuex";
import DragDropFileSource from "@/components/DragDropFileSource.vue";
import FsFileSource from "@/components/FsFileSource.vue";
import CameraFileSource from "@/components/CameraFileSource.vue";
import AuthenticatedImage from "@/components/AuthenticatedImage.vue"; import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
import DeletableWrapper from "@/components/DeletableWrapper.vue"; import DeletableWrapper from "@/components/DeletableWrapper.vue";
import WebcamFileSource from "@/components/WebcamFileSource.vue"; //import DragDropFileSource from "@/../extras/components/inputs/DragDropFileSource.vue";
//import CameraFileSource from "@/../extras/components/inputs/CameraFileSource.vue";
//import FsFileSource from "@/../extras/components/inputs/FsFileSource.vue";
import DragDropFileSource from "@/components/inputs/DragDropFileSource.vue";
import CameraFileSource from "@/components/inputs/CameraFileSource.vue";
import FsFileSource from "@/components/inputs/FsFileSource.vue";
import WebcamFileSource from "@/components/inputs/WebcamFileSource.vue";
export default { export default {
name: "CombinedFileField", name: "CombinedFileField",
components: { components: {
WebcamFileSource, WebcamFileSource,
...BIcons,
AuthenticatedImage, AuthenticatedImage,
DeletableWrapper, DeletableWrapper,
DragDropFileSource, DragDropFileSource,
CameraFileSource, CameraFileSource,
FsFileSource FsFileSource,
...BIcons
}, },
props: { props: {
item_files: { item_files: {
@ -182,7 +185,7 @@ export default {
only_images(files) { only_images(files) {
return files.filter(file => file.mime_type.startsWith("image/")); return files.filter(file => file.mime_type.startsWith("image/"));
}, },
whithout_images(files) { without_images(files) {
return files.filter(file => !file.mime_type.startsWith("image/")); return files.filter(file => !file.mime_type.startsWith("image/"));
} }
}, },

View file

@ -19,7 +19,9 @@
import * as BIcons from "bootstrap-icons-vue"; import * as BIcons from "bootstrap-icons-vue";
import {mapActions, mapState} from "vuex"; import {mapActions, mapState} from "vuex";
import PropertyBadge from "@/components/PropertyBadge.vue"; import PropertyBadge from "@/components/PropertyBadge.vue";
import BadgeSelectField from "@/components/BadgeSelectField.vue"; //import BadgeSelectField from "@/../extras/components/inputs/BadgeSelectField.vue";
import BadgeSelectField from "@/components/inputs/BadgeSelectField.vue";
export default { export default {
name: "PropertyField", name: "PropertyField",

View file

@ -21,7 +21,8 @@
<script> <script>
import * as BIcons from "bootstrap-icons-vue"; import * as BIcons from "bootstrap-icons-vue";
import {mapActions, mapState} from "vuex"; import {mapActions, mapState} from "vuex";
import BadgeSelectField from "@/components/BadgeSelectField.vue"; //import BadgeSelectField from "@/../extras/components/inputs/BadgeSelectField.vue";
import BadgeSelectField from "@/components/inputs/BadgeSelectField.vue";
export default { export default {
name: "TagField", name: "TagField",

View file

@ -33,16 +33,13 @@ class ServerSet {
} }
const url = "https://" + server + target // TODO https const url = "https://" + server + target // TODO https
return await fetch(url, { return await fetch(url, {
method: 'GET', method: 'GET', headers: {
headers: {
...auth.buildAuthHeader(url) ...auth.buildAuthHeader(url)
}, }, credentials: 'omit'
credentials: 'omit'
}).catch(err => { }).catch(err => {
console.error('get from server failed', server, err) console.error('get from server failed', server, err)
this.unreachable_neighbors.unreachable(server) this.unreachable_neighbors.unreachable(server)
} }).then(response => response.json())
).then(response => response.json())
} catch (e) { } catch (e) {
console.error('get from server failed', server, e) console.error('get from server failed', server, e)
} }
@ -61,18 +58,13 @@ class ServerSet {
} }
const url = "https://" + server + target // TODO https const url = "https://" + server + target // TODO https
return await fetch(url, { return await fetch(url, {
method: 'POST', method: 'POST', headers: {
headers: { 'Content-Type': 'application/json', ...auth.buildAuthHeader(url, data)
'Content-Type': 'application/json', }, credentials: 'omit', body: JSON.stringify(data)
...auth.buildAuthHeader(url, data)
},
credentials: 'omit',
body: JSON.stringify(data)
}).catch(err => { }).catch(err => {
console.error('post to server failed', server, err) console.error('post to server failed', server, err)
this.unreachable_neighbors.unreachable(server) this.unreachable_neighbors.unreachable(server)
} }).then(response => response.json())
).then(response => response.json())
} catch (e) { } catch (e) {
console.error('post to server failed', server, e) console.error('post to server failed', server, e)
} }
@ -91,18 +83,13 @@ class ServerSet {
} }
const url = "https://" + server + target // TODO https const url = "https://" + server + target // TODO https
return await fetch(url, { return await fetch(url, {
method: 'PATCH', method: 'PATCH', headers: {
headers: { 'Content-Type': 'application/json', ...auth.buildAuthHeader(url, data)
'Content-Type': 'application/json', }, credentials: 'omit', body: JSON.stringify(data)
...auth.buildAuthHeader(url, data)
},
credentials: 'omit',
body: JSON.stringify(data)
}).catch(err => { }).catch(err => {
console.error('patch to server failed', server, err) console.error('patch to server failed', server, err)
this.unreachable_neighbors.unreachable(server) this.unreachable_neighbors.unreachable(server)
} }).then(response => response.json())
).then(response => response.json())
} catch (e) { } catch (e) {
console.error('patch to server failed', server, e) console.error('patch to server failed', server, e)
} }
@ -121,18 +108,13 @@ class ServerSet {
} }
const url = "https://" + server + target // TODO https const url = "https://" + server + target // TODO https
return await fetch(url, { return await fetch(url, {
method: 'PUT', method: 'PUT', headers: {
headers: { 'Content-Type': 'application/json', ...auth.buildAuthHeader(url, data)
'Content-Type': 'application/json', }, credentials: 'omit', body: JSON.stringify(data)
...auth.buildAuthHeader(url, data)
},
credentials: 'omit',
body: JSON.stringify(data)
}).catch(err => { }).catch(err => {
console.error('put to server failed', server, err) console.error('put to server failed', server, err)
this.unreachable_neighbors.unreachable(server) this.unreachable_neighbors.unreachable(server)
} }).then(response => response.json())
).then(response => response.json())
} catch (e) { } catch (e) {
console.error('put to server failed', server, e) console.error('put to server failed', server, e)
} }
@ -151,16 +133,13 @@ class ServerSet {
} }
const url = "https://" + server + target // TODO https const url = "https://" + server + target // TODO https
return await fetch(url, { return await fetch(url, {
method: 'DELETE', method: 'DELETE', headers: {
headers: {
...auth.buildAuthHeader(url) ...auth.buildAuthHeader(url)
}, }, credentials: 'omit'
credentials: 'omit'
}).catch(err => { }).catch(err => {
console.error('delete from server failed', server, err) console.error('delete from server failed', server, err)
this.unreachable_neighbors.unreachable(server) this.unreachable_neighbors.unreachable(server)
} })
)
} catch (e) { } catch (e) {
console.error('delete from server failed', server, e) console.error('delete from server failed', server, e)
} }
@ -179,102 +158,159 @@ class ServerSet {
} }
const url = "https://" + server + target // TODO https const url = "https://" + server + target // TODO https
return await fetch(url, { return await fetch(url, {
method: 'GET', method: 'GET', headers: {
headers: {
...auth.buildAuthHeader(url) ...auth.buildAuthHeader(url)
}, }, credentials: 'omit'
credentials: 'omit'
}).catch(err => { }).catch(err => {
console.error('get from server failed', server, err) console.error('get from server failed', server, err)
this.unreachable_neighbors.unreachable(server) this.unreachable_neighbors.unreachable(server)
} })
)
} catch (e) { } catch (e) {
console.error('get from server failed', server, e) console.error('get from server failed', server, e)
} }
} }
throw new Error('all servers failed') throw new Error('all servers failed')
} }
async postRaw(auth, target, data) {
if (!auth || typeof auth.buildAuthHeader !== 'function') {
throw new Error('no auth')
}
for (const server of this.servers) {
try {
if (this.unreachable_neighbors.queryUnreachable(server)) {
continue
}
const url = "https://" + server + target // TODO https
return await fetch(url, {
method: 'POST', headers: {
'Content-Type': 'application/json', ...auth.buildAuthHeader(url, data)
}, credentials: 'omit', body: data
}).catch(err => {
console.error('post to server failed', server, err)
this.unreachable_neighbors.unreachable(server)
})
} catch (e) {
console.error('post to server failed', server, e)
}
}
throw new Error('all servers failed')
}
} }
class ServerSetUnion { //class ServerSetUnion {
constructor(serverSets) { // constructor(serverSets) {
if (!serverSets || !Array.isArray(serverSets)) { // if (!serverSets || !Array.isArray(serverSets)) {
throw new Error('no serverSets') // throw new Error('no serverSets')
} // }
this.serverSets = serverSets; // this.serverSets = serverSets;
} // }
//
// add(serverset) {
// if (!serverset || !(serverset instanceof ServerSet)) {
// throw new Error('no serverset')
// }
// if (this.serverSets.find(s => serverset.servers.every(s2 => s.servers.includes(s2)))) {
// console.warn('serverset already in union', serverset)
// return
// }
// this.serverSets.push(serverset)
// }
//
// async get(auth, target) {
// try {
// return await this.serverSets.reduce(async (acc, serverset) => {
// return acc.then(async (acc) => {
// return acc.concat(await serverset.get(auth, target))
// })
// }, Promise.resolve([]))
// } catch (e) {
// throw new Error('all servers failed')
// }
// }
//
// async post(auth, target, data) {
// try {
// return await this.serverSets.reduce(async (acc, serverset) => {
// return acc.then(async (acc) => {
// return acc.concat(await serverset.post(auth, target, data))
// })
// }, Promise.resolve([]))
// } catch (e) {
// throw new Error('all servers failed')
// }
// }
//
// async patch(auth, target, data) {
// try {
// return await this.serverSets.reduce(async (acc, serverset) => {
// return acc.then(async (acc) => {
// return acc.concat(await serverset.patch(auth, target, data))
// })
// }, Promise.resolve([]))
// } catch (e) {
// throw new Error('all servers failed')
// }
// }
//
// async put(auth, target, data) {
// try {
// return await this.serverSets.reduce(async (acc, serverset) => {
// return acc.then(async (acc) => {
// return acc.concat(await serverset.put(auth, target, data))
// })
// }, Promise.resolve([]))
// } catch (e) {
// throw new Error('all servers failed')
// }
// }
//
// async delete(auth, target) {
// try {
// return await this.serverSets.reduce(async (acc, serverset) => {
// return acc.then(async (acc) => {
// return acc.concat(await serverset.delete(auth, target))
// })
// }, Promise.resolve([]))
// } catch (e) {
// throw new Error('all servers failed')
// }
// }
//}
add(serverset) {
function ServerSetUnion(serverSets) {
return new Proxy(serverSets, {
get: function (target, prop, receiver) {
const funcs = Object.getOwnPropertyNames(ServerSet.prototype)
if (funcs.includes(prop)) {
return async function (...args) {
try {
return await target.reduce(async (acc, serverset) => {
return acc.then(async (acc) => {
return acc.concat(await serverset[prop](...args))
})
}, Promise.resolve([]))
} catch (e) {
throw new Error('all servers failed')
}
}
} else if (prop === 'add') {
return function (serverset) {
if (!serverset || !(serverset instanceof ServerSet)) { if (!serverset || !(serverset instanceof ServerSet)) {
throw new Error('no serverset') throw new Error('no serverset')
} }
if (this.serverSets.find(s => serverset.servers.every(s2 => s.servers.includes(s2)))) { if (target.find(s => serverset.servers.every(s2 => s.servers.includes(s2)))) {
console.warn('serverset already in union', serverset) console.warn('serverset already in union', serverset)
return return
} }
this.serverSets.push(serverset) target.push(serverset)
}
async get(auth, target) {
try {
return await this.serverSets.reduce(async (acc, serverset) => {
return acc.then(async (acc) => {
return acc.concat(await serverset.get(auth, target))
})
}, Promise.resolve([]))
} catch (e) {
throw new Error('all servers failed')
}
}
async post(auth, target, data) {
try {
return await this.serverSets.reduce(async (acc, serverset) => {
return acc.then(async (acc) => {
return acc.concat(await serverset.post(auth, target, data))
})
}, Promise.resolve([]))
} catch (e) {
throw new Error('all servers failed')
}
}
async patch(auth, target, data) {
try {
return await this.serverSets.reduce(async (acc, serverset) => {
return acc.then(async (acc) => {
return acc.concat(await serverset.patch(auth, target, data))
})
}, Promise.resolve([]))
} catch (e) {
throw new Error('all servers failed')
}
}
async put(auth, target, data) {
try {
return await this.serverSets.reduce(async (acc, serverset) => {
return acc.then(async (acc) => {
return acc.concat(await serverset.put(auth, target, data))
})
}, Promise.resolve([]))
} catch (e) {
throw new Error('all servers failed')
}
}
async delete(auth, target) {
try {
return await this.serverSets.reduce(async (acc, serverset) => {
return acc.then(async (acc) => {
return acc.concat(await serverset.delete(auth, target))
})
}, Promise.resolve([]))
} catch (e) {
throw new Error('all servers failed')
} }
} else {
return target[prop]
} }
},
});
} }

112
frontend/src/identity.js Normal file
View file

@ -0,0 +1,112 @@
async function deriveKeyFromPassword(password, salt = null, iter = 100000) {
if (!salt) {
salt = window.crypto.getRandomValues(new Uint8Array(16))
}
const pwbuffer = await window.crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password),
{name: "PBKDF2"},
false,
["deriveBits"]
);
const key_bits = await window.crypto.subtle.deriveBits(
{
name: "PBKDF2",
salt,
iterations: iter,
hash: "SHA-256"
},
pwbuffer,
256
)
const key = await window.crypto.subtle.importKey(
"raw",
key_bits,
{name: "AES-GCM"},
false,
["encrypt", "decrypt"]
)
return {key, salt, alg: 'pbkdf2', iter}
}
async function serializeIdentityRecord(user, keypair, password = null) {
if (password) {
const plain_key = `${nacl.to_hex(keypair.signSk).slice(0, 64)}:${nacl.to_hex(keypair.signPk).slice(56, 64)}`
const {key, salt, alg, iter} = await deriveKeyFromPassword(password)
const iv = window.crypto.getRandomValues(new Uint8Array(12))
const encrypted_key = await window.crypto.subtle.encrypt(
{
name: "AES-GCM",
iv
},
key,
new TextEncoder('utf-8').encode(plain_key)
)
return `${user}:$${alg}$${iter}$${nacl.to_hex(salt)}$${nacl.to_hex(iv)}$${nacl.to_hex(new Uint8Array(encrypted_key))}`
}
return `${user}:${nacl.to_hex(keypair.signSk).slice(0, 64)}:${nacl.to_hex(keypair.signPk).slice(56, 64)}`
}
function parseIdentityRecord(record) {
const segments = record.split(':');
if (segments.length === 3) {
try {
const [user, key_hex, sign_hex] = segments
const keypair = nacl.crypto_sign_keypair_from_seed(nacl.from_hex(key_hex))
const [username, domain] = user.split('@')
if (nacl.to_hex(keypair.signPk).slice(56, 64) !== sign_hex || username === '' || domain === '') {
return {user: null, keypair: null, errors: ['Invalid identity record']}
}
return {user, keypair, errors: null}
} catch (e) {
return {user: null, keypair: null, errors: ['Invalid identity record']}
}
} else if (segments.length === 2) {
try {
const [user, rest] = segments
if (!rest.startsWith('$')) {
return {user: null, keypair: null, errors: ['Invalid identity record']}
}
const [alg, iter, salt, iv, crypted] = rest.slice(1).split('$')
if (alg !== 'pbkdf2') {
return {user: null, keypair: null, errors: ['Invalid identity record']}
}
return {
user,
alg,
iter: parseInt(iter),
salt: nacl.from_hex(salt),
iv: nacl.from_hex(iv),
crypted: nacl.from_hex(crypted),
errors: null
}
} catch (e) {
return {user: null, keypair: null, errors: ['Invalid identity record']}
}
}
return {user: null, keypair: null, errors: ['Invalid identity record']}
}
async function decryptIdentityRecord(meta, user, password) {
try {
const {alg, iter, salt, iv, crypted} = meta
if (alg !== 'pbkdf2') {
return {user: null, keypair: null, errors: ['Unsupported algorithm']}
}
const {key} = await deriveKeyFromPassword(password, salt, iter)
const decrypted = await window.crypto.subtle.decrypt(
{
name: "AES-GCM",
iv
},
key,
crypted
)
const plain_key = new TextDecoder('utf-8').decode(decrypted)
return parseIdentityRecord(`${user}:${plain_key}`)
}catch (e) {
return {user: null, keypair: null, errors: ['could not decrypt identity record']}
}
}
export {parseIdentityRecord, serializeIdentityRecord, decryptIdentityRecord}

View file

@ -1,4 +1,5 @@
import {createApp} from 'vue' import {createApp} from 'vue'
import {BootstrapIconsPlugin} from 'bootstrap-icons-vue'; import {BootstrapIconsPlugin} from 'bootstrap-icons-vue';
import App from './App.vue' import App from './App.vue'
@ -16,6 +17,8 @@ _nacl.instantiate((nacl) => {
app.use(router).mount('#app') app.use(router).mount('#app')
}); });
//app.component(VueQrcode.name, VueQrcode);
window.closeAllDropdowns = function () { window.closeAllDropdowns = function () {
const dropdowns = document.getElementsByClassName("dropdown-menu"); const dropdowns = document.getElementsByClassName("dropdown-menu");
let i; let i;
@ -27,6 +30,7 @@ window.closeAllDropdowns = function () {
} }
} }
window.onclick = function (event) { window.onclick = function (event) {
if (!event.target.matches('.dropdown-toggle *') if (!event.target.matches('.dropdown-toggle *')
&& !event.target.matches('.dropdown-toggle') && !event.target.matches('.dropdown-toggle')
@ -39,7 +43,7 @@ window.onclick = function (event) {
&& !event.target.matches('.sidebar *') && !event.target.matches('.sidebar *')
&& !event.target.matches('.sidebar')) { && !event.target.matches('.sidebar')) {
const sidebar = document.getElementById("sidebar"); const sidebar = document.getElementById("sidebar");
const marginLeft = parseInt(getComputedStyle(sidebar).marginLeft); const marginLeft = parseInt(window.getComputedStyle(sidebar).marginLeft);
if (sidebar.classList.contains('collapsed') && marginLeft === 0) { if (sidebar.classList.contains('collapsed') && marginLeft === 0) {
sidebar.classList.remove('collapsed'); sidebar.classList.remove('collapsed');
} }

View file

@ -2,6 +2,7 @@ import {createRouter, createWebHistory} from 'vue-router'
import Dashboard from '@/views/Dashboard.vue'; import Dashboard from '@/views/Dashboard.vue';
import Login from '@/views/Login.vue'; import Login from '@/views/Login.vue';
import Register from '@/views/Register.vue'; import Register from '@/views/Register.vue';
import Pairing from '@/views/Pairing.vue';
import store from '@/store'; import store from '@/store';
import Profile from '@/views/Profile.vue'; import Profile from '@/views/Profile.vue';
import Settings from '@/views/Settings.vue'; import Settings from '@/views/Settings.vue';
@ -15,29 +16,67 @@ import Admin from '@/views/Admin.vue';
import Swatch from '@/views/Swatch.vue'; import Swatch from '@/views/Swatch.vue';
import Files from '@/views/Files.vue'; import Files from '@/views/Files.vue';
import Account from '@/views/settings/Account.vue';
import Password from '@/views/settings/Password.vue';
import Privacy from '@/views/settings/Privacy.vue';
import Email from '@/views/settings/Email.vue';
import Notifications from '@/views/settings/Notifications.vue';
import Data from '@/views/settings/Data.vue';
const routes = [
{path: '/', component: Dashboard, meta: {requiresAuth: true}}, const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
{path: '/profile', component: Profile, meta: {requiresAuth: true}}, path: '/inventory',
{path: '/settings', component: Settings, meta: {requiresAuth: true}}, component: Inventory,
{path: '/inventory', component: Inventory, meta: {requiresAuth: true}}, meta: {requiresAuth: true}
{path: '/inventory/:id', component: InventoryDetail, meta: {requiresAuth: true}, props: true}, }, {
{path: '/inventory/:id/edit', component: InventoryEdit, meta: {requiresAuth: true}, props: true}, path: '/inventory/:id',
{path: '/inventory/new', component: InventoryNew, meta: {requiresAuth: true}}, component: InventoryDetail,
{path: '/friends', component: Friends, meta: {requiresAuth: true}}, meta: {requiresAuth: true},
{path: '/files', component: Files, meta: {requiresAuth: true}}, props: true
{path: '/admin', component: Admin, meta: {requiresAuth: true}}, }, {
{path: '/swatch', component: Swatch, meta: {requiresAuth: true}}, path: '/inventory/:id/edit',
{path: '/search/:query', component: Search, meta: {requiresAuth: true}, props: true}, component: InventoryEdit,
{path: '/login', component: Login, meta: {requiresAuth: false}}, meta: {requiresAuth: true},
{path: '/register', component: Register, meta: {requiresAuth: false}}, props: true
{path: '/:pathMatch(.*)*', redirect: '/'} }, {path: '/inventory/new', component: InventoryNew, meta: {requiresAuth: true}}, {
] path: '/friends',
component: Friends,
meta: {requiresAuth: true}
}, {path: '/files', component: Files, meta: {requiresAuth: true}}, {
path: '/admin',
component: Admin,
meta: {requiresAuth: true}
}, {path: '/swatch', component: Swatch, meta: {requiresAuth: true}}, {
path: '/search/:query',
component: Search,
meta: {requiresAuth: true},
props: true
}, {path: '/login', component: Login, meta: {requiresAuth: false}}, {
path: '/register',
component: Register,
meta: {requiresAuth: false}
}, {path: '/pairing', component: Pairing, meta: {requiresAuth: false}}, {
path: '/profile',
component: Profile,
meta: {requiresAuth: true}
}, {
path: '/settings', component: Settings, meta: {requiresAuth: true}, children: [{
path: '', name: 'account', component: Account, meta: {requiresAuth: true}
},{
path: 'password/', name: 'password', component: Password, meta: {requiresAuth: true}
}, {
path: 'privacy/', name: 'privacy', component: Privacy, meta: {requiresAuth: true}
}, {
path: 'email/', name: 'email', component: Email, meta: {requiresAuth: true}
}, {
path: 'notifications/', name: 'notifications', component: Notifications, meta: {requiresAuth: true}
}, {
path: 'data/', name: 'data', component: Data, meta: {requiresAuth: true}
}]
}, {path: '/:pathMatch(.*)*', redirect: '/'}]
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(), linkActiveClass: "active", routes,
linkActiveClass: "active",
routes,
}) })
router.beforeEach((to/*, from*/) => { router.beforeEach((to/*, from*/) => {
@ -45,8 +84,7 @@ router.beforeEach((to/*, from*/) => {
if (to.meta.requiresAuth && !store.getters.isLoggedIn) { if (to.meta.requiresAuth && !store.getters.isLoggedIn) {
console.log("Not logged in, redirecting to login page") console.log("Not logged in, redirecting to login page")
return { return {
path: '/login', path: '/login', // save the location we were at to come back later
// save the location we were at to come back later
query: {redirect: to.fullPath}, query: {redirect: to.fullPath},
} }
} }

View file

@ -72,5 +72,9 @@
align-items: center; align-items: center;
padding: .25rem .7rem; padding: .25rem .7rem;
background-color: map-get($theme-colors, background-2); background-color: map-get($theme-colors, background-2);
#border-right-width: 0;
}
.input-group-text:not(:last-child) {
border-right-width: 0; border-right-width: 0;
} }

View file

@ -3,6 +3,9 @@ import router from '@/router';
import FallBackResolver from "@/dns"; import FallBackResolver from "@/dns";
import NeighborsCache from "@/neigbors"; import NeighborsCache from "@/neigbors";
import {createNullAuth, createSignAuth, createTokenAuth, ServerSet, ServerSetUnion} from "@/federation"; import {createNullAuth, createSignAuth, createTokenAuth, ServerSet, ServerSetUnion} from "@/federation";
import {parseIdentityRecord, serializeIdentityRecord} from "@/identity";
//import sharedStatePlugin from "@/../extras/shared-state-plugin";
//import persistentStatePlugin from "@/../extras/persistent-state-plugin";
export default createStore({ export default createStore({
@ -29,30 +32,6 @@ export default createStore({
storage_locations: [], storage_locations: [],
}, },
mutations: { mutations: {
setUser(state, user) {
state.user = user;
if (state.remember)
localStorage.setItem('user', user);
},
setToken(state, token) {
state.token = token;
if (state.remember)
localStorage.setItem('token', token);
},
setKey(state, keypair) {
state.keypair = nacl.crypto_sign_keypair_from_seed(nacl.from_hex(keypair))
if (state.remember)
localStorage.setItem('keypair', nacl.to_hex(state.keypair.signSk).slice(0, 64))
},
setRemember(state, remember) {
state.remember = remember;
if (!remember) {
localStorage.removeItem('user');
localStorage.removeItem('token');
localStorage.removeItem('keypair');
}
localStorage.setItem('remember', remember);
},
setInventoryItems(state, {url, items}) { setInventoryItems(state, {url, items}) {
state.item_map[url] = items; state.item_map[url] = items;
}, },
@ -86,6 +65,45 @@ export default createStore({
setFiles(state, files) { setFiles(state, files) {
state.files = files; state.files = files;
}, },
setUser(state, user) {
state.user = user;
if (state.remember)
localStorage.setItem('user', user);
},
setToken(state, token) {
state.token = token;
if (state.remember)
localStorage.setItem('token', token);
},
setKey(state, keypair) {
state.keypair = nacl.crypto_sign_keypair_from_seed(nacl.from_hex(keypair))
if (state.remember)
localStorage.setItem('keypair', nacl.to_hex(state.keypair.signSk).slice(0, 64))
},
setRemember(state, remember) {
state.remember = remember;
if (!remember) {
localStorage.removeItem('user');
localStorage.removeItem('token');
localStorage.removeItem('keypair');
}
localStorage.setItem('remember', remember);
},
setUserFromIdentityRecord(state, {user, keypair}) {
try {
localStorage.removeItem('user');
localStorage.removeItem('token');
localStorage.removeItem('keypair');
state.user = user;
state.keypair = keypair;
if (state.remember) {
localStorage.setItem('user', user);
localStorage.setItem('keypair', key)
}
} catch (e) {
console.error('Invalid identity record')
}
},
logout(state) { logout(state) {
state.user = null; state.user = null;
state.token = null; state.token = null;
@ -357,6 +375,9 @@ export default createStore({
state.last_load.categories = Date.now() state.last_load.categories = Date.now()
state.last_load.availability_policies = Date.now() state.last_load.availability_policies = Date.now()
return data return data
},
async userIdentityRecord({state}, {password}) {
return await serializeIdentityRecord(state.user, state.keypair, password);
} }
}, },
getters: { getters: {
@ -371,7 +392,7 @@ export default createStore({
state.local_loaded = true state.local_loaded = true
} }
return state.user !== null && state.token !== null; return state.user !== null && (state.token !== null || state.keypair !== null)
}, },
signAuth(state) { signAuth(state) {
return createSignAuth(state.user, state.keypair.signSk) return createSignAuth(state.user, state.keypair.signSk)

View file

@ -55,6 +55,10 @@
Dont have an account? Dont have an account?
<router-link to="/register">Sign up</router-link> <router-link to="/register">Sign up</router-link>
</p> </p>
<p class="mb-0 text-muted">
Connect with existing account?
<router-link to="/pairing">Pairing</router-link>
</p>
</div> </div>
</div> </div>
</div> </div>

View file

@ -0,0 +1,236 @@
<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">
<div :class="errors.raw_record?['mb-3','is-invalid']:['mb-3']">
<label class="form-label">Identity Record</label>
<div class="input-group">
<input class="form-control form-control-lg" type="text"
:class="errors.raw_record?['is-invalid']:[]"
v-model="raw_record" placeholder="Paste identity record"
@input.prevent="try_parsing">
<span class="input-group-text form-control-lg" v-if="raw_record == ''"
@click="pasteClipboard()">
<b-icon-clipboard></b-icon-clipboard>
</span>
<span class="input-group-text form-control-lg" v-else
@click="raw_record = ''">
<b-icon-x-circle></b-icon-x-circle>
</span>
</div>
<div class="invalid-feedback">
{{ errors.raw_record }}
</div>
</div>
<div class="mb-3" v-if="is_unlocked || is_locked">
<label class="form-label">Username</label>
<div class="input-group">
<input class="form-control form-control-lg" type="email"
disabled v-model="username">
<span class="input-group-text form-control-lg bg-success"
v-if="is_unlocked">
<b-icon-unlock></b-icon-unlock>
</span>
<span class="input-group-text form-control-lg bg-warning"
v-if="is_locked">
<b-icon-lock></b-icon-lock>
</span>
</div>
</div>
<div class="mb-3" v-if="is_locked" :class="errors.password?['is-invalid']:[]">
<form role="form" method="post" @submit.prevent="try_unlock">
<label class="form-label">Password</label>
<div class="input-group">
<input class="form-control form-control-lg" type="password"
name="password" placeholder="Enter your password"
v-model="password" :class="errors.password?['is-invalid']:[]">
<button class="btn btn-lg btn-primary" type="button"
@click="try_unlock">
<b-icon-unlock></b-icon-unlock>
unlock
</button>
</div>
<div class="invalid-feedback">
{{ errors.password }}
</div>
</form>
</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" class="btn btn-lg btn-primary" :disabled="!is_unlocked"
@click="try_pairing">
Pair
</button>
</div>
<br/>
<div class="text-center">
<p class="mb-0 text-muted">
Already have an account?
<router-link to="/login">Login</router-link>
</p>
<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 {mapActions, mapMutations} from 'vuex';
import {ServerSet, createSignAuth} from '../federation';
import NeighborsCache from '../neigbors';
import {decryptIdentityRecord, parseIdentityRecord, serializeIdentityRecord} from "@/identity";
import router from "@/router";
export default {
name: 'Pairing',
data() {
return {
msg: 'Pair with existing Account',
//raw_record: 'test_a@a.localhost:4ab79601edc400fd0263c7d5fd045ea7f5de28f1dd8905e2479f96893f3257d8:7f0ee42b',
//raw_record: 'test_a@a.localhost:$pbkdf2$100000$67da1c2a055dd41fc9707c37d2cdecea$8173b4d626352bcafab626b2$60027066142f55ecfd6f91b587f99c5f02ee536ce08c31e3f69148a51711489220a2b10d5450166ee09a781f891be8ab3a418e31a8e71f6468f189ba0baf31ece21166b63ba608683bdbea8afa7dfc33b1a994d8be4e4b2123',
raw_record: '',
crypted_record: null,
username: '',
password: '',
keypair: '',
remember: false,
errors: {}
}
},
computed: {
is_unlocked() {
return !!this.keypair && !!this.username;
},
is_locked() {
return !!this.username && !!this.crypted_record && !this.keypair;
}
},
methods: {
...mapActions(['lookupServer']),
...mapMutations(['setRemember', 'setUserFromIdentityRecord']),
async try_pairing() {
const unreachable = new NeighborsCache();
const servers = await this.lookupServer({username: this.username}).then(servers => new ServerSet(servers, unreachable))
const auth = createSignAuth(this.username, this.keypair.signSk);
const reply = await servers.getRaw(auth, '/api/friends/');
if (reply.status === 200) {
this.setUserFromIdentityRecord({username: this.username, keypair: this.keypair});
router.push({path: '/'});
} else {
console.error('Error:', reply);
}
},
try_parsing() {
if (!this.raw_record) {
this.username = '';
this.keypair = '';
this.errors = {};
return;
}
const parsed = parseIdentityRecord(this.raw_record)
const {user, keypair, errors} = parsed;
if (user) {
this.username = user;
}
if (errors) {
this.errors.raw_record = errors;
}
if (keypair) {
this.keypair = keypair;
}
if (parsed.crypted) {
this.crypted_record = parsed;
}
},
async try_unlock() {
const {
user,
keypair,
errors
} = await decryptIdentityRecord(this.crypted_record, this.username, this.password);
if (user) {
this.username = user;
}
if (errors) {
this.errors.password = errors;
}
if (keypair) {
this.keypair = keypair;
}
},
async pasteClipboard() {
try {
this.raw_record = await navigator.clipboard.readText();
this.try_parsing()
} catch (e) {
console.error('Error:', e);
}
}
},
mounted() {
try {
fetch('/local/domains')
.then(response => response.json())
.then(data => {
this.domains = data;
});
} catch (e) {
console.error('Error:', e);
}
}
}
</script>
<style scoped>
.is-invalid input, .is-invalid select {
border: 1px solid var(--bs-danger);
}
.is-invalid .invalid-feedback {
display: block;
}
.input-group-text svg {
overflow: visible;
}
</style>

View file

@ -27,9 +27,7 @@
type="text" v-model="form.username" id="validationCustomUsername" type="text" v-model="form.username" id="validationCustomUsername"
placeholder="Enter your username" required/> placeholder="Enter your username" required/>
<div class="input-group-prepend"> <span class="input-group-text form-control-lg">@</span>
<span class="input-group-text form-control form-control-lg">@</span>
</div>
<select class="form-control form-control-lg" <select class="form-control form-control-lg"
id="exampleFormControlSelect1" id="exampleFormControlSelect1"
placeholder="Domain" v-model="form.domain" required> placeholder="Domain" v-model="form.domain" required>
@ -77,6 +75,10 @@
Already have an account? Already have an account?
<router-link to="/login">Login</router-link> <router-link to="/login">Login</router-link>
</p> </p>
<p class="mb-0 text-muted">
Connect with existing account?
<router-link to="/pairing">Pairing</router-link>
</p>
</div> </div>
</div> </div>
</div> </div>

View file

@ -2,43 +2,32 @@
<BaseLayout> <BaseLayout>
<main class="content"> <main class="content">
<div class="container-fluid p-0"> <div class="container-fluid p-0">
<h1 class="h3 mb-3">Settings</h1> <h1 class="h3 mb-3">Settings</h1>
<div class="row"> <div class="row">
<div class="col-md-3 col-xl-2"> <div class="col-md-3 col-xl-2">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h5 class="card-title mb-0">Profile Settings</h5> <h5 class="card-title mb-0">Profile Settings</h5>
</div> </div>
<div class="list-group list-group-flush" role="tablist"> <div class="list-group list-group-flush" role="tablist">
<a class="list-group-item list-group-item-action" @click="active_tab = 'account'" <router-link class="list-group-item list-group-item-action" :to="{name: 'account'}"
:class="{ active: active_tab === 'account' }" href="#" role="tab"> active-class="dummy" exact-active-class="active">Account
Account </router-link>
</a> <router-link class="list-group-item list-group-item-action" :to="{name: 'password'}"
<a class="list-group-item list-group-item-action" @click="active_tab = 'password'" active-class="active">Password
:class="{ active: active_tab === 'password' }" href="#" </router-link>
role="tab"> <router-link class="list-group-item list-group-item-action" :to="{name: 'privacy'}"
Password active-class="active">Privacy and safety
</a> </router-link>
<a class="list-group-item list-group-item-action" @click="active_tab = 'privacy'" <router-link class="list-group-item list-group-item-action" :to="{name: 'email'}"
:class="{ active: active_tab === 'privacy' }" href="#" role="tab"> active-class="active">Email notifications
Privacy and safety </router-link>
</a> <router-link class="list-group-item list-group-item-action"
<a class="list-group-item list-group-item-action" @click="active_tab = 'email'" :to="{name: 'notifications'}" active-class="active">Web notifications
:class="{ active: active_tab === 'email' }" href="#" role="tab"> </router-link>
Email notifications <router-link class="list-group-item list-group-item-action" :to="{name: 'data'}"
</a> active-class="active">Your data
<a class="list-group-item list-group-item-action" @click="active_tab = 'notifications'" </router-link>
:class="{ active: active_tab === 'notifications' }" href="#">
Web notifications
</a>
<a class="list-group-item list-group-item-action" @click="active_tab = 'data'"
:class="{ active: active_tab === 'data' }" href="#">
Your data
</a>
<a class="list-group-item list-group-item-action delete" @click="deleteAccount" <a class="list-group-item list-group-item-action delete" @click="deleteAccount"
href="#"> href="#">
Delete account Delete account
@ -46,306 +35,12 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-9 col-xl-10"> <div class="col-md-9 col-xl-10">
<div class="tab-content"> <div class="tab-content">
<div class="tab-pane fade" :class="{ 'show active': active_tab === 'account' }" id="account" <router-view></router-view>
role="tabpanel">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Public info</h5>
</div>
<div class="card-body">
<form>
<div class="row">
<div class="col-md-8">
<div class="mb-3">
<label class="form-label"
for="inputUsername">Username</label>
<input type="text" class="form-control" id="inputUsername"
placeholder="Username">
</div>
<div class="mb-3">
<label class="form-label"
for="inputUsername">Biography</label>
<textarea rows="2" class="form-control" id="inputBio"
placeholder="Tell something about yourself"></textarea>
</div>
</div>
<div class="col-md-4">
<div class="text-center">
<img alt="Charles Hall"
src="/assets/img/avatars/avatar.png"
class="rounded-circle img-responsive mt-2" width="128"
height="128"/>
<div class="mt-2">
<span class="btn btn-primary"><i
class="fas fa-upload"></i> Upload</span>
</div>
<small>For best results, use an image at least 128px by
128px in .jpg format</small>
</div> </div>
</div> </div>
</div> </div>
<button type="submit" class="btn btn-primary">Save changes</button>
</form>
</div>
</div>
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Private info</h5>
</div>
<div class="card-body">
<form>
<div class="row">
<div class="mb-3 col-md-6">
<label class="form-label" for="inputFirstName">First
name</label>
<input type="text" class="form-control" id="inputFirstName"
placeholder="First name">
</div>
<div class="mb-3 col-md-6">
<label class="form-label" for="inputLastName">Last name</label>
<input type="text" class="form-control" id="inputLastName"
placeholder="Last name">
</div>
</div>
<div class="mb-3">
<label class="form-label" for="inputEmail4">Email</label>
<input type="email" class="form-control" id="inputEmail4"
placeholder="Email">
</div>
<div class="mb-3">
<label class="form-label" for="inputAddress">Address</label>
<input type="text" class="form-control" id="inputAddress"
placeholder="1234 Main St">
</div>
<div class="mb-3">
<label class="form-label" for="inputAddress2">Address 2</label>
<input type="text" class="form-control" id="inputAddress2"
placeholder="Apartment, studio, or floor">
</div>
<div class="row">
<div class="mb-3 col-md-6">
<label class="form-label" for="inputCity">City</label>
<input type="text" class="form-control" id="inputCity">
</div>
<div class="mb-3 col-md-4">
<label class="form-label" for="inputState">State</label>
<select id="inputState" class="form-control">
<option selected>Choose...</option>
<option>...</option>
</select>
</div>
<div class="mb-3 col-md-2">
<label class="form-label" for="inputZip">Zip</label>
<input type="text" class="form-control" id="inputZip">
</div>
</div>
<button type="submit" class="btn btn-primary">Save changes</button>
</form>
</div>
</div>
</div>
<div class="tab-pane fade" :class="{ 'show active': active_tab === 'password' }"
id="password" role="tabpanel">
<div class="card">
<div class="card-body">
<h5 class="card-title">Password</h5>
<form>
<div class="mb-3">
<label class="form-label" for="inputPasswordCurrent">Current
password</label>
<input type="password" class="form-control"
id="inputPasswordCurrent">
<small><a href="#">Forgot your password?</a></small>
</div>
<div class="mb-3">
<label class="form-label" for="inputPasswordNew">New
password</label>
<input type="password" class="form-control" id="inputPasswordNew">
</div>
<div class="mb-3">
<label class="form-label" for="inputPasswordNew2">Verify
password</label>
<input type="password" class="form-control" id="inputPasswordNew2">
</div>
<button type="submit" class="btn btn-primary">Save changes</button>
</form>
</div>
</div>
</div>
<div class="tab-pane fade" :class="{ 'show active': active_tab === 'privacy' }" id="privacy"
role="tabpanel">
<div class="card">
<div class="card-body">
<h5 class="card-title">Privacy and safety</h5>
<form>
<div class="mb-3">
<label class="form-label d-block">Profile visibility</label>
<div class="form-check form-switch">
<input class="form-check input-switch" type="checkbox"
id="flexSwitchCheckDefault">
<label class="form-check
label-switch" for="flexSwitchCheckDefault">Visible to
everyone</label>
</div>
</div>
<div class="mb-3">
<label class="form-label
d-block">Photo visibility</label>
<div class="form-check
form-switch">
<input class="form-check
input-switch" type="checkbox"
id="flexSwitchCheckDefault">
<label class="form-check
label-switch" for="flexSwitchCheckDefault">Visible to
everyone</label>
</div>
</div>
<div class="mb-3">
<label class="form-label
d-block">Last seen</label>
<div class="form-check
form-switch">
<input class="form-check
input-switch" type="checkbox"
id="flexSwitchCheckDefault">
<label class="form-check
label-switch" for="flexSwitchCheckDefault">Visible to
everyone</label>
</div>
</div>
<button type="submit" class="btn btn-primary">Save
changes
</button>
</form>
</div>
<div class="card-footer">
<small>Changes will be reflected to all your devices</small>
</div>
</div>
</div>
<div class="tab-pane fade" :class="{ 'show active': active_tab === 'email' }" id="email"
role="tabpanel">
<div class="card">
<div class="card-body">
<h5 class="card-title
mb-0">Email notifications</h5>
<form>
<div class="mb-3">
<label class="form-label
d-block">Email notifications</label>
<div class="form-check
form-switch">
<input class="form-check
input-switch" type="checkbox"
id="flexSwitchCheckDefault">
<label class="form-check
label-switch" for="flexSwitchCheckDefault">Receive
email notifications</label>
</div>
</div>
<div class="mb-3">
<label class="form-label
d-block">Send email notifications to</label>
<input type="email" class="form-control"
id="inputEmail">
</div>
<button type="submit" class="btn btn-primary">Save
changes
</button>
</form>
</div>
</div>
</div>
<div class="tab-pane fade" :class="{ 'show active': active_tab === 'notifications' }"
id="notifications" role="tabpanel">
<div class="card">
<div class="card-body">
<h5 class="card-title mb-0">Web notifications</h5>
<form>
<div class="mb-3">
<label class="form-label d-block">Web notifications</label>
<div class="form-check form-switch">
<input class="form-check input-switch" type="checkbox"
id="flexSwitchCheckDefault">
<label class="form-check label-switch" for="flexSwitchCheckDefault">Receive
web notifications</label>
<small>These notifications can be sent to your email or
phone</small>
</div>
</div>
<div class="mb-3">
<label class="form-label d-block">Send web notifications to</label>
<input type="email" class="form-control"
id="inputEmail">
</div>
<button type="submit" class="btn btn-primary">Save changes</button>
</form>
</div>
</div>
</div>
<div class="tab-pane fade" :class="{ 'show active': active_tab === 'data' }" id="data"
role="tabpanel">
<div class="card">
<div class="card-body">
<h5 class="card-title mb-0">Export</h5>
<form>
<div class="mb-3">
<label class="form-label
d-block">Download your data</label>
<button type="button" class="btn btn-primary">Download</button>
<small>Download all your data including photos, videos,
comments, profile information and more</small>
</div>
<div class="mb-3">
<label class="form-label
d-block">Delete your data</label>
<button type="button" class="btn btn-danger">Delete</button>
<small>Delete all your data including photos, videos,
comments, profile information and more</small>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-body">
<h5 class="card-title mb-0">Import</h5>
<form>
<div class="mb-3">
<label class="form-label
d-block">Import data</label>
<button type="button" class="btn btn-primary">Import</button>
<small>Import data from backup or other instances</small>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</main> </main>
</BaseLayout> </BaseLayout>
@ -357,15 +52,12 @@ import BaseLayout from "@/components/BaseLayout.vue";
export default { export default {
name: 'Settings', name: 'Settings',
components: {BaseLayout}, components: {BaseLayout},
data: () => ({
active_tab: "account"
}),
methods: { methods: {
deleteAccount() { deleteAccount() {
if (confirm('Are you sure you want to delete your account?')) { if (confirm('Are you sure you want to delete your account?')) {
alert('Account deleted'); alert('Account deleted');
} }
} },
} }
} }
</script> </script>

View file

@ -0,0 +1,111 @@
<template>
<div>
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Public info</h5>
</div>
<div class="card-body">
<form>
<div class="row">
<div class="col-md-8">
<div class="mb-3">
<label class="form-label"
for="inputUsername">Username</label>
<input type="text" class="form-control" id="inputUsername"
placeholder="Username">
</div>
<div class="mb-3">
<label class="form-label"
for="inputUsername">Biography</label>
<textarea rows="2" class="form-control" id="inputBio"
placeholder="Tell something about yourself"></textarea>
</div>
</div>
<div class="col-md-4">
<div class="text-center">
<img alt="Charles Hall"
src="/assets/img/avatars/avatar.png"
class="rounded-circle img-responsive mt-2" width="128"
height="128"/>
<div class="mt-2">
<span class="btn btn-primary"><i
class="fas fa-upload"></i> Upload</span>
</div>
<small>For best results, use an image at least 128px by
128px in .jpg format</small>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary">Save changes</button>
</form>
</div>
</div>
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Private info</h5>
</div>
<div class="card-body">
<form>
<div class="row">
<div class="mb-3 col-md-6">
<label class="form-label" for="inputFirstName">First
name</label>
<input type="text" class="form-control" id="inputFirstName"
placeholder="First name">
</div>
<div class="mb-3 col-md-6">
<label class="form-label" for="inputLastName">Last name</label>
<input type="text" class="form-control" id="inputLastName"
placeholder="Last name">
</div>
</div>
<div class="mb-3">
<label class="form-label" for="inputEmail4">Email</label>
<input type="email" class="form-control" id="inputEmail4"
placeholder="Email">
</div>
<div class="mb-3">
<label class="form-label" for="inputAddress">Address</label>
<input type="text" class="form-control" id="inputAddress"
placeholder="1234 Main St">
</div>
<div class="mb-3">
<label class="form-label" for="inputAddress2">Address 2</label>
<input type="text" class="form-control" id="inputAddress2"
placeholder="Apartment, studio, or floor">
</div>
<div class="row">
<div class="mb-3 col-md-6">
<label class="form-label" for="inputCity">City</label>
<input type="text" class="form-control" id="inputCity">
</div>
<div class="mb-3 col-md-4">
<label class="form-label" for="inputState">State</label>
<select id="inputState" class="form-control">
<option selected>Choose...</option>
<option>...</option>
</select>
</div>
<div class="mb-3 col-md-2">
<label class="form-label" for="inputZip">Zip</label>
<input type="text" class="form-control" id="inputZip">
</div>
</div>
<button type="submit" class="btn btn-primary">Save changes</button>
</form>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'Account',
components: {}
}
</script>
<style scoped>
</style>

View file

@ -0,0 +1,147 @@
<template>
<div>
<div class="card">
<div class="card-body">
<h5 class="card-title mb-0">Backup Account Key</h5>
<form>
<div class="mb-3">
<label class="form-label d-block">Backup your account key</label>
<button type="button" class="btn btn-primary" @click="exportKey">
Backup
</button>
<br>
<small>Backup your account key to restore your account in case
of loss</small>
<br>
<p>{{ localUserIdentityRecord }}</p>
<vue-qrcode :value="'foo'" tag="svg" :size="200" :options="{errorCorrectionLevel: 'H'}"></vue-qrcode>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-body">
<h5 class="card-title mb-0">Export</h5>
<form>
<div class="mb-3">
<label class="form-label
d-block">Download your data</label>
<button type="button" class="btn btn-primary" @click="exportData">
Download
</button>
<br>
<small>Download all your data including photos, videos,
comments, profile information and more</small>
</div>
<div class="mb-3">
<label class="form-label
d-block">Delete your data</label>
<button type="button" class="btn btn-danger" @click="deleteData">
Delete
</button>
<br>
<small>Delete all your data including photos, videos,
comments, profile information and more</small>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-body">
<h5 class="card-title mb-0">Import</h5>
<form>
<div class="mb-3">
<label class="form-label
d-block">Import data</label>
<div class="btn-group">
<input type="file" class="form-control" id="inputFile"
accept=".zip" @change="chooseFile">
<button type="button" class="btn btn-primary" @click="importData"
:disabled="!selectedFile">
Import
</button>
</div>
<br>
<small>Import data from backup or other instances</small>
</div>
</form>
</div>
</div>
</div>
</template>
<script>
import {mapActions, mapGetters, mapMutations} from 'vuex';
import router from "@/router";
//import VueQrcode from '@chenfengyuan/vue-qrcode';
export default {
name: 'Data',
components: {},
data: () => ({
selectedFile: null,
localUserIdentityRecord: null
}),
computed: {
...mapGetters(['signAuth'])
},
methods: {
...mapActions(['getHomeServers', 'userIdentityRecord']),
chooseFile(event) {
const file = event.target.files[0];
if (file) {
this.selectedFile = file;
}
},
deleteData() {
if (confirm('Are you sure you want to delete your data?')) {
alert('Data deleted');
}
},
exportKey() {
const key = this.userIdentityRecord;
},
async importData() {
if (!this.selectedFile) {
alert('Please select a file to import');
return;
}
alert('Data import not implemented');
return;
const formData = new FormData();
formData.append('file', this.selectedFile);
console.log(formData);
const servers = await this.getHomeServers();
const data = await servers.postRaw(this.signHashedAuth, '/api/import/', formData);
console.log(data);
},
async exportData() {
const servers = await this.getHomeServers();
const data = await servers.getRaw(this.signAuth, '/api/export/');
const blob = new Blob([await data.blob()], {type: 'application/zip'});
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const date_str = new Date().toISOString().replace(/T/, ' ').replace(/\..+/, '').replace(/:/g, '-');
a.download = 'toolshed_data_' + date_str + '.zip';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
},
mounted() {
this.userIdentityRecord({}).then((data) => {
this.localUserIdentityRecord = data;
});
}
}
</script>
<style scoped>
</style>

View file

@ -0,0 +1,41 @@
<template>
<div>
<div class="card">
<div class="card-body">
<h5 class="card-title mb-0">Email notifications</h5>
<form>
<div class="mb-3">
<label class="form-label d-block">Email notifications</label>
<div class="form-check form-switch">
<input class="form-check input-switch" type="checkbox"
id="flexSwitchCheckDefault">
<label class="form-check label-switch" for="flexSwitchCheckDefault">Receive
email notifications</label>
</div>
</div>
<div class="mb-3">
<label class="form-label d-block">Send email notifications to</label>
<input type="email" class="form-control"
id="inputEmail">
</div>
<button type="submit" class="btn btn-primary">Save
changes
</button>
</form>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'Email',
components: {}
}
</script>
<style scoped>
</style>

View file

@ -0,0 +1,42 @@
<template>
<div>
<div class="card">
<div class="card-body">
<h5 class="card-title mb-0">Web notifications</h5>
<form>
<div class="mb-3">
<label class="form-label d-block">Web notifications</label>
<div class="form-check form-switch">
<input class="form-check input-switch" type="checkbox"
id="flexSwitchCheckDefault">
<label class="form-check label-switch" for="flexSwitchCheckDefault">Receive
web notifications</label>
<small>These notifications can be sent to your email or
phone</small>
</div>
</div>
<div class="mb-3">
<label class="form-label d-block">Send web notifications to</label>
<input type="email" class="form-control"
id="inputEmail">
</div>
<button type="submit" class="btn btn-primary">Save changes</button>
</form>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'Notifications',
components: {}
}
</script>
<style scoped>
</style>

View file

@ -0,0 +1,41 @@
<template>
<div>
<div class="card">
<div class="card-body">
<h5 class="card-title">Password</h5>
<form>
<div class="mb-3">
<label class="form-label" for="inputPasswordCurrent">Current
password</label>
<input type="password" class="form-control"
id="inputPasswordCurrent">
<small><a href="#">Forgot your password?</a></small>
</div>
<div class="mb-3">
<label class="form-label" for="inputPasswordNew">New
password</label>
<input type="password" class="form-control" id="inputPasswordNew">
</div>
<div class="mb-3">
<label class="form-label" for="inputPasswordNew2">Verify
password</label>
<input type="password" class="form-control" id="inputPasswordNew2">
</div>
<button type="submit" class="btn btn-primary">Save changes</button>
</form>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'Password',
components: {},
}
</script>
<style scoped>
</style>

View file

@ -0,0 +1,68 @@
<template>
<div>
<div class="card">
<div class="card-body">
<h5 class="card-title">Privacy and safety</h5>
<form>
<div class="mb-3">
<label class="form-label d-block">Profile visibility</label>
<div class="form-check form-switch">
<input class="form-check input-switch" type="checkbox"
id="flexSwitchCheckDefault">
<label class="form-check
label-switch" for="flexSwitchCheckDefault">Visible to
everyone</label>
</div>
</div>
<div class="mb-3">
<label class="form-label
d-block">Photo visibility</label>
<div class="form-check
form-switch">
<input class="form-check
input-switch" type="checkbox"
id="flexSwitchCheckDefault">
<label class="form-check
label-switch" for="flexSwitchCheckDefault">Visible to
everyone</label>
</div>
</div>
<div class="mb-3">
<label class="form-label
d-block">Last seen</label>
<div class="form-check
form-switch">
<input class="form-check
input-switch" type="checkbox"
id="flexSwitchCheckDefault">
<label class="form-check
label-switch" for="flexSwitchCheckDefault">Visible to
everyone</label>
</div>
</div>
<button type="submit" class="btn btn-primary">Save
changes
</button>
</form>
</div>
<div class="card-footer">
<small>Changes will be reflected to all your devices</small>
</div>
</div>
</div>
</template>
<script>
import {mapActions, mapGetters, mapMutations} from 'vuex';
import router from "@/router";
export default {
name: 'Privacy',
components: {}
}
</script>
<style scoped>
</style>

View file

@ -19,11 +19,12 @@ export default defineConfig({
'Access-Control-Allow-Headers': 'Origin, Content-Type, X-Auth-Token, Authorization, Accept, charset, boundary, Content-Length', 'Access-Control-Allow-Headers': 'Origin, Content-Type, X-Auth-Token, Authorization, Accept, charset, boundary, Content-Length',
'Access-Control-Allow-Credentials': 'true', 'Access-Control-Allow-Credentials': 'true',
'Access-Control-Max-Age': '86400', 'Access-Control-Max-Age': '86400',
//'Upgrade-Insecure-Requests': '1',
'Content-Security-Policy': 'default-src \'self\';' 'Content-Security-Policy': 'default-src \'self\';'
+ ' script-src \'self\' \'wasm-unsafe-eval\';' + ' script-src \'self\' \'wasm-unsafe-eval\' \'unsafe-eval\';'
+ ' style-src \'self\' \'unsafe-inline\';' + ' style-src \'self\' \'unsafe-inline\';'
+ ' img-src \'self\' https://* data:;' + ' img-src \'self\' * data:;'
+ ' connect-src https://* data:', + ' connect-src * data:',
}, },
}, },
test: { test: {