stash
This commit is contained in:
parent
9ce700c388
commit
35be834799
8 changed files with 92 additions and 15 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -56,13 +56,16 @@ const EXPANDED_ROUTE_BUILDERS = {
|
||||||
itemDetailRoute(store.getters.identityHandleById[owner_identity_id], item_local_id),
|
itemDetailRoute(store.getters.identityHandleById[owner_identity_id], item_local_id),
|
||||||
group_item: ({owner_group_id, item_local_id}) =>
|
group_item: ({owner_group_id, item_local_id}) =>
|
||||||
itemDetailRoute(store.getters.groupHandleById[owner_group_id], item_local_id),
|
itemDetailRoute(store.getters.groupHandleById[owner_group_id], item_local_id),
|
||||||
group: ({group_id}) => `/groups/${group_id}`,
|
group: ({group_id}) => {
|
||||||
|
const handle = store.getters.groupHandleById[group_id];
|
||||||
|
return handle ? `/groups/${encodeHandleForUrl(handle)}` : null;
|
||||||
|
},
|
||||||
storage_location: ({storage_location_id}) => `/storage-locations/${storage_location_id}`,
|
storage_location: ({storage_location_id}) => `/storage-locations/${storage_location_id}`,
|
||||||
workflow: ({workflow_id}) => `/workflows/${workflow_id}`,
|
workflow: ({workflow_id}) => `/workflows/${workflow_id}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Kinds whose route needs identityHandleById/groupHandleById (from state.idmap); ShortId.vue only waits on an idmap fetch for these.
|
// Kinds whose route needs identityHandleById/groupHandleById (from state.idmap); ShortId.vue only waits on an idmap fetch for these.
|
||||||
export const NEEDS_IDMAP = new Set(['item', 'group_item']);
|
export const NEEDS_IDMAP = new Set(['item', 'group_item', 'group']);
|
||||||
|
|
||||||
export function expandedRoute({kind, ...fields}) {
|
export function expandedRoute({kind, ...fields}) {
|
||||||
const buildRoute = EXPANDED_ROUTE_BUILDERS[kind];
|
const buildRoute = EXPANDED_ROUTE_BUILDERS[kind];
|
||||||
|
|
@ -117,7 +120,7 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
||||||
component: GroupNew,
|
component: GroupNew,
|
||||||
meta: {requiresAuth: true}
|
meta: {requiresAuth: true}
|
||||||
}, {
|
}, {
|
||||||
path: '/groups/:id',
|
path: '/groups/:handle',
|
||||||
component: GroupDetail,
|
component: GroupDetail,
|
||||||
meta: {requiresAuth: true},
|
meta: {requiresAuth: true},
|
||||||
props: true
|
props: true
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,7 @@ import {mapActions, mapGetters, mapState} from "vuex";
|
||||||
import * as BIcons from "bootstrap-icons-vue";
|
import * as BIcons from "bootstrap-icons-vue";
|
||||||
import BaseLayout from "@/components/BaseLayout.vue";
|
import BaseLayout from "@/components/BaseLayout.vue";
|
||||||
import UserNameTag from "@/components/UserNameTag.vue";
|
import UserNameTag from "@/components/UserNameTag.vue";
|
||||||
import {encodeHandleForUrl} from "@/router";
|
import {decodeHandleFromUrl, encodeHandleForUrl} from "@/router";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'GroupDetail',
|
name: 'GroupDetail',
|
||||||
|
|
@ -144,8 +144,8 @@ export default {
|
||||||
...BIcons
|
...BIcons
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
id: {
|
handle: {
|
||||||
type: [String, Number],
|
type: String,
|
||||||
required: true
|
required: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -159,14 +159,17 @@ export default {
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapState(['user']),
|
...mapState(['user']),
|
||||||
|
id() {
|
||||||
|
return this.groupIdByHandle[decodeHandleFromUrl(this.handle)]
|
||||||
|
},
|
||||||
items() {
|
items() {
|
||||||
return this.groupInventoryItems(this.id)
|
return this.groupInventoryItems(this.id)
|
||||||
},
|
},
|
||||||
...mapGetters(['groupInventoryItems'])
|
...mapGetters(['groupInventoryItems', 'groupIdByHandle'])
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions(['fetchGroup', 'removeGroupMember', 'inviteToGroup', 'fetchGroupInventoryItems',
|
...mapActions(['fetchGroup', 'removeGroupMember', 'inviteToGroup', 'fetchGroupInventoryItems',
|
||||||
'deleteInventoryItem']),
|
'deleteInventoryItem', 'fetchIdMap']),
|
||||||
refresh() {
|
refresh() {
|
||||||
this.fetchGroup({id: this.id}).then((group) => {
|
this.fetchGroup({id: this.id}).then((group) => {
|
||||||
this.group = group
|
this.group = group
|
||||||
|
|
@ -201,8 +204,15 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
if (this.id === undefined) {
|
||||||
|
this.fetchIdMap().then(() => {
|
||||||
this.refresh()
|
this.refresh()
|
||||||
this.fetchItems()
|
this.fetchItems()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.refresh()
|
||||||
|
this.fetchItems()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
<script>
|
<script>
|
||||||
import {mapActions} from "vuex";
|
import {mapActions} from "vuex";
|
||||||
import BaseLayout from "@/components/BaseLayout.vue";
|
import BaseLayout from "@/components/BaseLayout.vue";
|
||||||
|
import {encodeHandleForUrl} from "@/router";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'GroupNew',
|
name: 'GroupNew',
|
||||||
|
|
@ -40,7 +41,7 @@ export default {
|
||||||
...mapActions(['createGroup']),
|
...mapActions(['createGroup']),
|
||||||
tryCreateGroup() {
|
tryCreateGroup() {
|
||||||
this.createGroup({name: this.name}).then((group) => {
|
this.createGroup({name: this.name}).then((group) => {
|
||||||
this.$router.push(`/groups/${group.id}`)
|
this.$router.push(`/groups/${encodeHandleForUrl(group.handle)}`)
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="group in groups" :key="group.id">
|
<tr v-for="group in groups" :key="group.id">
|
||||||
<td>
|
<td>
|
||||||
<router-link :to="`/groups/${group.id}`">{{ group.handle }}</router-link>
|
<router-link :to="`/groups/${encodeHandleForUrl(group.handle)}`">{{ group.handle }}</router-link>
|
||||||
</td>
|
</td>
|
||||||
<td class="d-none d-md-table-cell">{{ group.members.length }}</td>
|
<td class="d-none d-md-table-cell">{{ group.members.length }}</td>
|
||||||
<td class="table-action"></td>
|
<td class="table-action"></td>
|
||||||
|
|
@ -38,6 +38,32 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-12 col-xl-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title">Other groups you're a member of</h5>
|
||||||
|
</div>
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Group</th>
|
||||||
|
<th style="width: 16em">
|
||||||
|
<a @click="fetchGroupMemberships" class="align-middle">
|
||||||
|
<b-icon-arrow-clockwise></b-icon-arrow-clockwise>
|
||||||
|
Refresh
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="membership in foreignGroupMemberships" :key="membership.id">
|
||||||
|
<td>{{ membership.handle }}</td>
|
||||||
|
<td class="table-action"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="col-12 col-xl-6">
|
<div class="col-12 col-xl-6">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
|
|
@ -85,6 +111,7 @@
|
||||||
import {mapActions, mapState} from "vuex";
|
import {mapActions, mapState} from "vuex";
|
||||||
import * as BIcons from "bootstrap-icons-vue";
|
import * as BIcons from "bootstrap-icons-vue";
|
||||||
import BaseLayout from "@/components/BaseLayout.vue";
|
import BaseLayout from "@/components/BaseLayout.vue";
|
||||||
|
import {encodeHandleForUrl} from "@/router";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'Groups',
|
name: 'Groups',
|
||||||
|
|
@ -93,14 +120,21 @@ export default {
|
||||||
...BIcons
|
...BIcons
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapState(['groups', 'groupInvites']),
|
...mapState(['groups', 'groupInvites', 'groupMemberships']),
|
||||||
|
foreignGroupMemberships() {
|
||||||
|
const hostedHandles = new Set(this.groups.map(group => group.handle))
|
||||||
|
return this.groupMemberships.filter(membership => !hostedHandles.has(membership.handle))
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions(['fetchGroups', 'fetchGroupInvites', 'acceptGroupInvite', 'declineGroupInvite']),
|
encodeHandleForUrl,
|
||||||
|
...mapActions(['fetchGroups', 'fetchGroupInvites', 'fetchGroupMemberships', 'acceptGroupInvite',
|
||||||
|
'declineGroupInvite']),
|
||||||
tryAcceptInvite(invite) {
|
tryAcceptInvite(invite) {
|
||||||
this.acceptGroupInvite(invite).then(() => {
|
this.acceptGroupInvite(invite).then(() => {
|
||||||
this.fetchGroupInvites()
|
this.fetchGroupInvites()
|
||||||
this.fetchGroups()
|
this.fetchGroups()
|
||||||
|
this.fetchGroupMemberships()
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
@ -114,6 +148,7 @@ export default {
|
||||||
mounted() {
|
mounted() {
|
||||||
this.fetchGroups()
|
this.fetchGroups()
|
||||||
this.fetchGroupInvites()
|
this.fetchGroupInvites()
|
||||||
|
this.fetchGroupMemberships()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,30 @@ import {existsSync} from 'node:fs'
|
||||||
import {defineConfig} from 'vite'
|
import {defineConfig} from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
// Vite's dev-server SPA fallback only rewrites a request to index.html when the last path
|
||||||
|
// segment has no dot (it sniffs for a file extension). A route whose only/last segment is a
|
||||||
|
// URL-encoded handle (e.g. /groups/+user@a.localhost, see encodeHandleForUrl in src/router.js)
|
||||||
|
// legitimately contains a dot from the domain part, so a direct load/refresh 404s there unless
|
||||||
|
// we pre-empt that check ourselves. Gating on both a text/html Accept header and an '@' in the
|
||||||
|
// last segment keeps this from ever matching real asset requests or vite's own internal
|
||||||
|
// @fs/@id/@vite module paths (which never carry an html Accept header).
|
||||||
|
function handleUrlFallback() {
|
||||||
|
return {
|
||||||
|
name: 'handle-url-fallback',
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use((req, res, next) => {
|
||||||
|
const accept = req.headers.accept || ''
|
||||||
|
const pathname = (req.url || '').split('?')[0]
|
||||||
|
const lastSegment = pathname.slice(pathname.lastIndexOf('/') + 1)
|
||||||
|
if (req.method === 'GET' && accept.includes('text/html') && lastSegment.includes('@')) {
|
||||||
|
req.url = '/index.html'
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function gitCommit() {
|
function gitCommit() {
|
||||||
// deploy/dev/docker-compose.yml bind-mounts the repo's real .git dir at
|
// deploy/dev/docker-compose.yml bind-mounts the repo's real .git dir at
|
||||||
// /git (separate from /app, which only has the frontend/ subtree) -
|
// /git (separate from /app, which only has the frontend/ subtree) -
|
||||||
|
|
@ -23,7 +47,7 @@ function gitCommit() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue(), handleUrlFallback()],
|
||||||
define: {
|
define: {
|
||||||
__GIT_COMMIT__: JSON.stringify(gitCommit())
|
__GIT_COMMIT__: JSON.stringify(gitCommit())
|
||||||
},
|
},
|
||||||
|
|
@ -44,6 +68,10 @@ export default defineConfig({
|
||||||
//'Upgrade-Insecure-Requests': '1',
|
//'Upgrade-Insecure-Requests': '1',
|
||||||
'Content-Security-Policy': 'default-src \'self\';'
|
'Content-Security-Policy': 'default-src \'self\';'
|
||||||
+ ' script-src \'self\' \'wasm-unsafe-eval\' \'unsafe-eval\' \'unsafe-inline\';'
|
+ ' script-src \'self\' \'wasm-unsafe-eval\' \'unsafe-eval\' \'unsafe-inline\';'
|
||||||
|
// Without this, worker-src falls back to script-src, which has no blob: source -
|
||||||
|
// CameraScanner.vue's decode Worker (built from a Blob URL) would be silently
|
||||||
|
// blocked by CSP rather than the app's own capability checks.
|
||||||
|
+ ' worker-src \'self\' blob:;'
|
||||||
+ ' style-src \'self\' \'unsafe-inline\';'
|
+ ' style-src \'self\' \'unsafe-inline\';'
|
||||||
+ ' img-src \'self\' * data: blob:;'
|
+ ' img-src \'self\' * data: blob:;'
|
||||||
+ ' connect-src * data:',
|
+ ' connect-src * data:',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue