This commit is contained in:
j3d1 2026-08-24 12:36:17 +02:00
parent 8f3236b5b4
commit 8d96bc97c4
3 changed files with 62 additions and 11 deletions

View file

@ -63,6 +63,12 @@ const EXPANDED_ROUTE_BUILDERS = {
workflow: ({workflow_id}) => `/workflows/${workflow_id}`, workflow: ({workflow_id}) => `/workflows/${workflow_id}`,
}; };
// Only these two builders read identityHandleById/groupHandleById (derived from state.idmap) -
// ShortId.vue checks this to decide whether a cold-open fetch of idmap is worth waiting on before
// giving up, so a storage_location/group/workflow/file short id never waits on an unrelated
// network call.
export const NEEDS_IDMAP = new Set(['item', 'group_item']);
export function expandedRoute({kind, ...fields}) { export function expandedRoute({kind, ...fields}) {
const buildRoute = EXPANDED_ROUTE_BUILDERS[kind]; const buildRoute = EXPANDED_ROUTE_BUILDERS[kind];
return buildRoute ? buildRoute(fields) : null; return buildRoute ? buildRoute(fields) : null;
@ -96,13 +102,24 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
path: '/i/:handle/:id', path: '/i/:handle/:id',
redirect: to => `/inventory/${to.params.handle}/${to.params.id}` redirect: to => `/inventory/${to.params.handle}/${to.params.id}`
}, { }, {
// A beforeEnter guard, not `redirect`: `redirect` is called synchronously and its return value
// is used as-is (never awaited), and it also *must* resolve to a valid location on every match
// (an unresolvable one throws, see vue-router's handleRedirectRecord) - it can't itself wait on
// fetchIdMap (see NEEDS_IDMAP) for the item/group_item kinds whose owner handle isn't
// resolvable from the token alone. A guard can return `null`/undefined to mean "proceed to the
// component instead", which is exactly what's needed here: when expandedRoute can't resolve yet
// (or ever - an unrecognized kind), stay on this same URL and mount ShortId.vue in place, which
// has full component-lifecycle async support and takes it from there - fetch idmap, retry,
// redirect once resolved, or keep showing the decode view.
path: '/:short_id', path: '/:short_id',
redirect: to => { component: ShortId,
console.log(to) props: true,
beforeEnter: to => {
console.log(to);
const p = deserializeShortId(decodeShortId(to.params.short_id)) const p = deserializeShortId(decodeShortId(to.params.short_id))
console.log(p) console.log(p)
const url = expandedRoute(p) const url = expandedRoute(p);
console.log(url) console.log(url);
return url; return url;
} }
}, {path: '/inventory/new', component: InventoryNew, meta: {requiresAuth: true}}, { }, {path: '/inventory/new', component: InventoryNew, meta: {requiresAuth: true}}, {
@ -197,11 +214,6 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
path: '/debug/:short_id', path: '/debug/:short_id',
component: ShortId, component: ShortId,
props: true props: true
}, {
path: '/:short_id',
redirect: to => {
}
}, {path: '/:pathMatch(.*)*', redirect: '/'}] }, {path: '/:pathMatch(.*)*', redirect: '/'}]
const router = createRouter({ const router = createRouter({

View file

@ -75,6 +75,7 @@ export default createStore({
groups: [], groups: [],
groupInvites: [], groupInvites: [],
idmap: {identities: [], groups: []}, idmap: {identities: [], groups: []},
idmapLoaded: false,
item_map: {}, item_map: {},
home_servers: null, home_servers: null,
all_friends_servers: null, all_friends_servers: null,
@ -115,6 +116,7 @@ export default createStore({
}, },
setIdMap(state, idmap) { setIdMap(state, idmap) {
state.idmap = idmap; state.idmap = idmap;
state.idmapLoaded = true;
}, },
setHomeServers(state, home_servers) { setHomeServers(state, home_servers) {
state.home_servers = home_servers; state.home_servers = home_servers;
@ -334,9 +336,18 @@ export default createStore({
// signature check on the receiving end fail against the real request. // signature check on the receiving end fail against the real request.
(answer) => answer.port === 443 ? answer.target : answer.target + ':' + answer.port)) (answer) => answer.port === 443 ? answer.target : answer.target + ':' + answer.port))
}, },
async getHomeServers({state, dispatch, commit}) { async getHomeServers({state, dispatch, commit, getters}) {
if (state.home_servers) if (state.home_servers)
return state.home_servers return state.home_servers
// isLoggedIn (store.js's getters) is what lazily hydrates state.user/token/keypair
// from localStorage on first read - a route with no requiresAuth meta (e.g. the
// short-id redirect) never triggers that beforeEach check, so state.user can still be
// null here even for an actually-logged-in visitor. Reading the getter first forces
// that hydration; if it's still false afterwards, the visitor really isn't logged in,
// so fail with a clear error instead of lookupServer crashing on username.split(...).
if (!getters.isLoggedIn) {
throw new Error('Not logged in')
}
const promise = dispatch('lookupServer', {username: state.user}).then(servers => new ServerSet(servers, state.unreachable_neighbors)) const promise = dispatch('lookupServer', {username: state.user}).then(servers => new ServerSet(servers, state.unreachable_neighbors))
commit('setHomeServers', promise) commit('setHomeServers', promise)
return promise return promise

View file

@ -23,6 +23,7 @@
<div class="card-header">Expanded</div> <div class="card-header">Expanded</div>
<div class="card-body"> <div class="card-body">
<router-link v-if="expandedRoute" :to="expandedRoute">{{ expandedRoute }}</router-link> <router-link v-if="expandedRoute" :to="expandedRoute">{{ expandedRoute }}</router-link>
<span v-else-if="error" class="text-danger">{{ error }}</span>
<span v-else>No page exists for kind '{{ deserialized.kind }}'</span> <span v-else>No page exists for kind '{{ deserialized.kind }}'</span>
</div> </div>
</div> </div>
@ -61,9 +62,10 @@
</template> </template>
<script> <script>
import {mapActions} from 'vuex';
import BaseLayout from '@/components/BaseLayout.vue'; import BaseLayout from '@/components/BaseLayout.vue';
import {decodeShortId, deserializeShortId, encodeShortId, serializeShortId} from '@/short-id'; import {decodeShortId, deserializeShortId, encodeShortId, serializeShortId} from '@/short-id';
import {expandedRoute as buildExpandedRoute} from '@/router'; import {expandedRoute as buildExpandedRoute, NEEDS_IDMAP} from '@/router';
const EXAMPLES = [ const EXAMPLES = [
{kind: 'item', owner_identity_id: 7, item_local_id: 42}, {kind: 'item', owner_identity_id: 7, item_local_id: 42},
@ -128,6 +130,11 @@ export default {
required: true required: true
} }
}, },
data() {
return {
error: null
}
},
computed: { computed: {
decoded() { decoded() {
return decodeShortId(this.short_id); return decodeShortId(this.short_id);
@ -150,6 +157,27 @@ export default {
return {kind, fields, ints, token, bits: shortIdBitSegments(ints)}; return {kind, fields, ints, token, bits: shortIdBitSegments(ints)};
}); });
} }
},
watch: {
// expandedRoute reads Vuex getters derived from state.idmap (see buildExpandedRoute in
// router.js), so it re-evaluates on its own once fetchIdMap resolves below - this just
// catches that and finishes the redirect the router's own (synchronous, can't-await)
// redirect couldn't.
expandedRoute(url) {
if (url) {
this.$router.replace(url);
}
}
},
methods: {
...mapActions(['fetchIdMap'])
},
mounted() {
if (NEEDS_IDMAP.has(this.deserialized.kind) && !this.$store.state.idmapLoaded) {
this.fetchIdMap().catch(e => {
this.error = e.message;
});
}
} }
} }
</script> </script>