diff --git a/frontend/src/router.js b/frontend/src/router.js index b37ece4..fd8d9d1 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -51,6 +51,11 @@ function itemDetailRoute(handle, item_local_id) { return handle ? `/inventory/${encodeHandleForUrl(handle)}/${item_local_id}` : null; } +// storage_location/group_storage_location share this /storage-locations/:handle/:id shape, same idea as itemDetailRoute above. +function storageLocationDetailRoute(handle, storage_location_id) { + return handle ? `/storage-locations/${encodeHandleForUrl(handle)}/${storage_location_id}` : null; +} + export function ownerOverviewRoute(item) { return item.owner_group ? `/groups/${encodeHandleForUrl(item.owner_group)}` : '/inventory'; } @@ -64,12 +69,15 @@ const EXPANDED_ROUTE_BUILDERS = { 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: ({owner_identity_id, storage_location_id}) => + storageLocationDetailRoute(store.getters.identityHandleById[owner_identity_id], storage_location_id), + group_storage_location: ({owner_group_id, storage_location_id}) => + storageLocationDetailRoute(store.getters.groupHandleById[owner_group_id], 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', 'group']); +export const NEEDS_IDMAP = new Set(['item', 'group_item', 'group', 'storage_location', 'group_storage_location']); export function expandedRoute({kind, ...fields}) { const buildRoute = EXPANDED_ROUTE_BUILDERS[kind]; @@ -188,12 +196,12 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, { component: StorageLocation, meta: {requiresAuth: true} }, { - path: '/storage-locations/:id', + path: '/storage-locations/:handle/:id', component: StorageLocationDetail, meta: {requiresAuth: true}, props: true }, { - path: '/storage-locations/:id/edit', + path: '/storage-locations/:handle/:id/edit', component: StorageLocationEdit, meta: {requiresAuth: true}, props: true diff --git a/frontend/src/short-id.js b/frontend/src/short-id.js index f26e4f7..1f79672 100644 --- a/frontend/src/short-id.js +++ b/frontend/src/short-id.js @@ -40,6 +40,12 @@ const SCHEMAS = { fields: ['category_id'], interpret: ([category_id]) => ({kind: 'category', category_id}) }, + '4-2': { + name: 'group_storage_location', + fields: ['owner_group_id', 'storage_location_id'], + interpret: ([owner_group_id, storage_location_id]) => + ({kind: 'group_storage_location', owner_group_id, storage_location_id}) + }, } const BASE64URL_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_' diff --git a/frontend/src/store.js b/frontend/src/store.js index 162b980..b89377d 100644 --- a/frontend/src/store.js +++ b/frontend/src/store.js @@ -83,6 +83,7 @@ export default createStore({ idmap: {identities: [], groups: []}, idmapLoaded: false, item_map: {}, + location_map: {}, home_servers: null, all_friends_servers: null, messages: [], @@ -105,6 +106,9 @@ export default createStore({ setInventoryItems(state, {url, items}) { state.item_map[url] = items; }, + setLocationsForKey(state, {url, locations}) { + state.location_map[url] = locations; + }, setFriends(state, friends) { state.friends = friends; }, @@ -397,16 +401,17 @@ export default createStore({ return items }, async createInventoryItem({state, dispatch, getters}, item) { - const servers = await dispatch('getHomeServers') - const data = {availability_policy: 'private', ...item} + const servers = item.owner_group + ? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain}) + : await dispatch('getHomeServers') + const data = { + availability_policy: 'private', ...item, + owner_group: item.owner_group ? item.owner_group.slice(1) : null + } const reply = await servers.post(getters.signAuth, '/api/inventory_items/', data) state.last_load.files = 0 return reply }, - // A group-owned item lives on its owning group's own backend, not necessarily the - // caller's home one (see the group actions below) - item.owner_group ("#name@domain") - // already carries that domain, so update/delete resolve it the same way rather than - // needing the caller to track it separately. async updateInventoryItem({state, dispatch, getters}, item) { const servers = item.owner_group ? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain}) @@ -465,16 +470,10 @@ export default createStore({ return null; } }, - // A group handle resolves via fetchGroup (itself domain-routed, see below), so a group - // hosted elsewhere works exactly like one hosted on the caller's own backend - no idmap - // detour needed here. ServerSet.get() never checks response.ok, so a 404 (nonexistent or - // non-member group) surfaces as a JSON-parse rejection rather than resolving falsy - same - // try/catch shape as fetchForeignItem. async fetchItemByHandle({dispatch, getters}, {handle, id}) { if (handle.startsWith('#')) { try { - const group = await dispatch('fetchGroup', {handle}) - const items = await dispatch('fetchGroupInventoryItems', {groupId: group.id, domain: group.domain}) + const items = await dispatch('fetchGroupInventoryItems', {groupHandle: handle}) return items.find(item => item.id === parseInt(id)) || null } catch (error) { console.error(`Failed to fetch group item ${id} for ${handle}:`, error); @@ -530,14 +529,6 @@ export default createStore({ const servers = await dispatch('getHomeServers') return await servers.delete(getters.signAuth, '/api/friends/' + id + '/') }, - // Listing/creating a group is inherently home-scoped - it's this backend's own view of - // "groups I host you in" - but every action below that acts on an *existing* group - // (found via its handle, e.g. from a GroupMembership pointer to a group hosted - // elsewhere - see GroupMembership/docs/design-in-progress/groups-mvp.md) resolves that - // group's own domain and talks to it directly, the same way requestFriend/ - // fetchForeignItem resolve a friend's domain, rather than assuming home. A group hosted - // on the caller's own domain goes through exactly the same code path - there's nothing - // to special-case. async fetchGroups({commit, dispatch, getters}) { const servers = await dispatch('getHomeServers') const data = await servers.get(getters.signAuth, '/api/groups/') @@ -552,17 +543,19 @@ export default createStore({ }, // handle is "#name@domain". async fetchGroup({dispatch, getters}, {handle}) { - const {name, domain} = splitGroupHandle(handle) + const {domain} = splitGroupHandle(handle) const servers = await dispatch('getFriendServers', {username: 'x@' + domain}) - return await servers.get(getters.signAuth, `/api/groups/handle/${name}/${domain}/`) + return await servers.get(getters.signAuth, `/api/groups/${handle.slice(1)}/`) }, async createGroup({dispatch, getters}, {name}) { const servers = await dispatch('getHomeServers') return await servers.post(getters.signAuth, '/api/groups/', {name}) }, - async removeGroupMember({dispatch, getters}, {groupId, domain, identityId}) { + async removeGroupMember({dispatch, getters}, {groupHandle, identityId}) { + const {domain} = splitGroupHandle(groupHandle) const servers = await dispatch('getFriendServers', {username: 'x@' + domain}) - return await servers.delete(getters.signAuth, '/api/groups/' + groupId + '/members/' + identityId + '/') + return await servers.delete( + getters.signAuth, '/api/groups/' + groupHandle.slice(1) + '/members/' + identityId + '/') }, async fetchGroupInvites({commit, dispatch, getters}) { const servers = await dispatch('getHomeServers') @@ -576,10 +569,11 @@ export default createStore({ commit('setGroupMemberships', data) return data }, - async inviteToGroup({state, dispatch, getters}, {groupId, domain, groupHandle, invitee}) { + async inviteToGroup({state, dispatch, getters}, {groupHandle, invitee}) { + const {domain} = splitGroupHandle(groupHandle) const group_servers = await dispatch('getFriendServers', {username: 'x@' + domain}) const group_reply = await group_servers.post( - getters.signAuth, '/api/groups/' + groupId + '/invites/', {invitee}) + getters.signAuth, '/api/groups/' + groupHandle.slice(1) + '/invites/', {invitee}) if (!group_reply.secret) { return false } @@ -610,13 +604,14 @@ export default createStore({ const servers = await dispatch('getHomeServers') return await servers.delete(getters.signAuth, '/api/groupinvites/' + invite.id + '/') }, - async fetchGroupInventoryItems({commit, dispatch, getters}, {groupId, domain}) { + async fetchGroupInventoryItems({commit, dispatch, getters}, {groupHandle}) { + const {domain} = splitGroupHandle(groupHandle) const servers = await dispatch('getFriendServers', {username: 'x@' + domain}) - const items = await servers.get(getters.signAuth, '/api/inventory_items/?group=' + groupId) - // Namespaced by domain, not just groupId: that pk is only unique within its own + const items = await servers.get(getters.signAuth, '/api/inventory_items/?group=' + groupHandle.slice(1)) + // Keyed by the full handle, not a bare id: a group pk is only unique within its own // backend's database, so two different domains could otherwise collide on the same - // item_map key. - commit('setInventoryItems', {url: '/group/' + domain + '/' + groupId, items}) + // item_map key - the handle already carries the domain, so no separate namespacing is needed. + commit('setInventoryItems', {url: '/group/' + groupHandle, items}) return items }, async fetchFiles({state, commit, dispatch, getters}) { @@ -722,23 +717,50 @@ export default createStore({ commit('setStorageLocations', data) state.last_load.storage_locations = Date.now() return data + },async fetchGroupStorageLocations({commit, dispatch, getters}, {groupHandle}) { + const {domain} = splitGroupHandle(groupHandle) + const servers = await dispatch('getFriendServers', {username: 'x@' + domain}) + const locations = await servers.get(getters.signAuth, '/api/storage_locations/?group=' + groupHandle.slice(1)) + commit('setLocationsForKey', {url: '/group/' + groupHandle, locations}) + return locations + }, + async fetchStorageLocationByHandle({dispatch, getters}, {handle, id}) { + if (handle.startsWith('#')) { + try { + const locations = await dispatch('fetchGroupStorageLocations', {groupHandle: handle}) + return locations.find(location => location.id === parseInt(id)) || null + } catch (error) { + console.error(`Failed to fetch group storage location ${id} for ${handle}:`, error); + return null; + } + } + const locations = await dispatch('fetchStorageLocations') + return locations.find(location => location.id === parseInt(id)) || null }, async deleteStorageLocation({state, dispatch, getters}, location) { - const servers = await dispatch('getHomeServers') + const servers = location.owner_group + ? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(location.owner_group).domain}) + : await dispatch('getHomeServers') const ret = await servers.delete(getters.signAuth, '/api/storage_locations/' + location.id + '/') dispatch('fetchStorageLocations') return ret }, + // location.owner_group is the group's own "#name@domain" handle - see + // createInventoryItem's own comment. async createStorageLocation({state, dispatch, getters}, location) { - const servers = await dispatch('getHomeServers') - const data = {...location} + const servers = location.owner_group + ? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(location.owner_group).domain}) + : await dispatch('getHomeServers') + const data = {...location, owner_group: location.owner_group ? location.owner_group.slice(1) : null} if (data.parent === '') data.parent = null const reply = await servers.post(getters.signAuth, '/api/storage_locations/', data) state.last_load.storage_locations = 0 return reply }, async updateStorageLocation({state, dispatch, getters}, location) { - const servers = await dispatch('getHomeServers') + const servers = location.owner_group + ? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(location.owner_group).domain}) + : await dispatch('getHomeServers') const data = {...location} if (data.parent === '') data.parent = null const reply = await servers.patch(getters.signAuth, '/api/storage_locations/' + location.id + '/', data) @@ -848,15 +870,14 @@ export default createStore({ inventory_items(state) { return state.item_map['/'] || [] }, - groupInventoryItems: (state) => (groupId, domain) => state.item_map['/group/' + domain + '/' + groupId] || [], + groupInventoryItems: (state) => (groupHandle) => state.item_map['/group/' + groupHandle] || [], + groupStorageLocations: (state) => (groupHandle) => state.location_map['/group/' + groupHandle] || [], identityIdByHandle(state) { return Object.fromEntries(state.idmap.identities.map(i => [i.username, i.id])) }, groupIdByHandle(state) { return Object.fromEntries(state.idmap.groups.map(g => [g.handle, g.id])) }, - // Reverse of the two getters above: turns a short-id's raw owner_identity_id/owner_group_id - // back into a handle (see router.js EXPANDED_ROUTE_BUILDERS), no backend lookup needed. identityHandleById(state) { return Object.fromEntries(state.idmap.identities.map(i => [i.id, i.username])) }, diff --git a/frontend/src/tests/short-id.js b/frontend/src/tests/short-id.js index df53525..8e92fb5 100644 --- a/frontend/src/tests/short-id.js +++ b/frontend/src/tests/short-id.js @@ -21,6 +21,7 @@ test('every kind round-trips through serialize/encode/decode/deserialize', () => {kind: 'group_item', owner_group_id: 5, item_local_id: 42}, {kind: 'group', group_id: 11}, {kind: 'storage_location', owner_identity_id: 3, storage_location_id: 1000}, + {kind: 'group_storage_location', owner_group_id: 5, storage_location_id: 1000}, {kind: 'file', file_id: 123}, {kind: 'workflow', owner_identity_id: 2, workflow_id: 9}, ] diff --git a/frontend/src/views/GroupDetail.vue b/frontend/src/views/GroupDetail.vue index 21eaaa9..b079b59 100644 --- a/frontend/src/views/GroupDetail.vue +++ b/frontend/src/views/GroupDetail.vue @@ -127,7 +127,55 @@
| Name | +Path | +Category | +Actions | +
|---|---|---|---|
|
+ |
+ + {{ location.path }} + | ++ {{ location.category }} + - + | +
+ |
+