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),
group_item: ({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}`,
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.
export const NEEDS_IDMAP = new Set(['item', 'group_item']);
export const NEEDS_IDMAP = new Set(['item', 'group_item', 'group']);
export function expandedRoute({kind, ...fields}) {
const buildRoute = EXPANDED_ROUTE_BUILDERS[kind];
@ -117,7 +120,7 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
component: GroupNew,
meta: {requiresAuth: true}
}, {
path: '/groups/:id',
path: '/groups/:handle',
component: GroupDetail,
meta: {requiresAuth: true},
props: true

View file

@ -134,7 +134,7 @@ import {mapActions, mapGetters, mapState} from "vuex";
import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
import UserNameTag from "@/components/UserNameTag.vue";
import {encodeHandleForUrl} from "@/router";
import {decodeHandleFromUrl, encodeHandleForUrl} from "@/router";
export default {
name: 'GroupDetail',
@ -144,8 +144,8 @@ export default {
...BIcons
},
props: {
id: {
type: [String, Number],
handle: {
type: String,
required: true
}
},
@ -159,14 +159,17 @@ export default {
},
computed: {
...mapState(['user']),
id() {
return this.groupIdByHandle[decodeHandleFromUrl(this.handle)]
},
items() {
return this.groupInventoryItems(this.id)
},
...mapGetters(['groupInventoryItems'])
...mapGetters(['groupInventoryItems', 'groupIdByHandle'])
},
methods: {
...mapActions(['fetchGroup', 'removeGroupMember', 'inviteToGroup', 'fetchGroupInventoryItems',
'deleteInventoryItem']),
'deleteInventoryItem', 'fetchIdMap']),
refresh() {
this.fetchGroup({id: this.id}).then((group) => {
this.group = group
@ -201,8 +204,15 @@ export default {
}
},
mounted() {
this.refresh()
this.fetchItems()
if (this.id === undefined) {
this.fetchIdMap().then(() => {
this.refresh()
this.fetchItems()
})
} else {
this.refresh()
this.fetchItems()
}
}
}
</script>

View file

@ -25,6 +25,7 @@
<script>
import {mapActions} from "vuex";
import BaseLayout from "@/components/BaseLayout.vue";
import {encodeHandleForUrl} from "@/router";
export default {
name: 'GroupNew',
@ -40,7 +41,7 @@ export default {
...mapActions(['createGroup']),
tryCreateGroup() {
this.createGroup({name: this.name}).then((group) => {
this.$router.push(`/groups/${group.id}`)
this.$router.push(`/groups/${encodeHandleForUrl(group.handle)}`)
}).catch(() => {
})
}

View file

@ -29,7 +29,7 @@
<tbody>
<tr v-for="group in groups" :key="group.id">
<td>
<router-link :to="`/groups/${group.id}`">{{ group.handle }}</router-link>
<router-link :to="`/groups/${encodeHandleForUrl(group.handle)}`">{{ group.handle }}</router-link>
</td>
<td class="d-none d-md-table-cell">{{ group.members.length }}</td>
<td class="table-action"></td>
@ -38,6 +38,32 @@
</table>
</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="card">
<div class="card-header">
@ -85,6 +111,7 @@
import {mapActions, mapState} from "vuex";
import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
import {encodeHandleForUrl} from "@/router";
export default {
name: 'Groups',
@ -93,14 +120,21 @@ export default {
...BIcons
},
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: {
...mapActions(['fetchGroups', 'fetchGroupInvites', 'acceptGroupInvite', 'declineGroupInvite']),
encodeHandleForUrl,
...mapActions(['fetchGroups', 'fetchGroupInvites', 'fetchGroupMemberships', 'acceptGroupInvite',
'declineGroupInvite']),
tryAcceptInvite(invite) {
this.acceptGroupInvite(invite).then(() => {
this.fetchGroupInvites()
this.fetchGroups()
this.fetchGroupMemberships()
}).catch(() => {
})
},
@ -114,6 +148,7 @@ export default {
mounted() {
this.fetchGroups()
this.fetchGroupInvites()
this.fetchGroupMemberships()
}
}
</script>

View file

@ -5,6 +5,30 @@ import {existsSync} from 'node:fs'
import {defineConfig} from 'vite'
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() {
// deploy/dev/docker-compose.yml bind-mounts the repo's real .git dir at
// /git (separate from /app, which only has the frontend/ subtree) -
@ -23,7 +47,7 @@ function gitCommit() {
}
export default defineConfig({
plugins: [vue()],
plugins: [vue(), handleUrlFallback()],
define: {
__GIT_COMMIT__: JSON.stringify(gitCommit())
},
@ -44,6 +68,10 @@ export default defineConfig({
//'Upgrade-Insecure-Requests': '1',
'Content-Security-Policy': 'default-src \'self\';'
+ ' 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\';'
+ ' img-src \'self\' * data: blob:;'
+ ' connect-src * data:',