This commit is contained in:
j3d1 2026-08-26 18:37:06 +02:00
parent 9ce700c388
commit 35be834799
8 changed files with 92 additions and 15 deletions

View file

@ -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

View file

@ -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() {
this.refresh() if (this.id === undefined) {
this.fetchItems() this.fetchIdMap().then(() => {
this.refresh()
this.fetchItems()
})
} else {
this.refresh()
this.fetchItems()
}
} }
} }
</script> </script>

View file

@ -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(() => {
}) })
} }

View file

@ -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>

View file

@ -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:',