Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions src/services/auth/entityOwnership.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { EntityAccessKind } from '@/models/auth.types'
import { logger } from '@/utils/logger'

/**
* Bring client-side permissions back in step after the user created an entity.
*
* WebAPI reports per-entity grants only through `/user/me`, which the client
* fetches at startup, so a newly created entity has no grant until the next
* page load and the editor's Save and Delete actions stay disabled on the
* thing the user just made.
*
* Two steps, in this order:
*
* 1. Record the current user as owner of `id`. A POST that succeeded means the
* server accepted them as the creator, so the buttons come alive without
* waiting on a second round trip.
* 2. Re-read `/user/me`, which replaces that optimistic grant with what the
* server actually granted. This is what picks up the grants a create
* cascades into (an imported design creates cohorts and concept sets of its
* own) and what corrects step 1 if the server did not grant write after all.
*
* Step 1 is the fallback for step 2: if `/user/me` cannot be reached the
* optimistic grant stands rather than leaving the user stuck.
*
* The store is imported lazily to keep the service layer free of a static
* dependency on Pinia, matching the other store touch points in services.
*/
export async function syncAccessAfterCreate(
kind: EntityAccessKind,
id: string | number | null | undefined
): Promise<void> {
if (id === null || id === undefined || id === '') return
try {
const { useAuthStore } = await import('@/stores/auth')
const authStore = useAuthStore()
authStore.registerOwnedEntity(kind, id)
await authStore.refreshUserContext()
} catch (error) {
// A missing Pinia context (tests, plugin sandboxes) must not fail the
// create call that already succeeded on the server.
logger.warn('EntityOwnership', `Failed to register ownership of ${kind} ${id}`, error)
}
}
13 changes: 10 additions & 3 deletions src/services/characterization.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/
import { httpGet, httpPost, httpPut, httpDelete, httpPostRead } from '@/services/http-client'
import { unwrap, unwrapList, ApiError, parseOrThrow } from '@/services/api-error'
import { syncAccessAfterCreate } from '@/services/auth/entityOwnership'
import { logger } from '@/utils/logger'
import { type ApiResult } from '@/types/api'
import {
Expand Down Expand Up @@ -76,11 +77,13 @@ export async function createCharacterization(
): Promise<ApiResult<CharacterizationDefinition>> {
return unwrap(async () => {
const data = await httpPost<unknown>('/cohort-characterization', serializeCharacterization(def))
return parseOrThrow(
const created = parseOrThrow(
CharacterizationDefinitionSchema,
data,
'Invalid response from POST /cohort-characterization'
) as CharacterizationDefinition
await syncAccessAfterCreate('cohortCharacterization', created.id)
return created
}, CONTEXT)
}

Expand Down Expand Up @@ -127,11 +130,13 @@ export async function copyCharacterization(
): Promise<ApiResult<CharacterizationDefinition>> {
return unwrap(async () => {
const data = await httpPost<unknown>(`/cohort-characterization/${id}`)
return parseOrThrow(
const copied = parseOrThrow(
CharacterizationDefinitionSchema,
data,
`Invalid response from POST /cohort-characterization/${id}`
) as CharacterizationDefinition
await syncAccessAfterCreate('cohortCharacterization', copied.id)
return copied
}, CONTEXT)
}

Expand Down Expand Up @@ -172,11 +177,13 @@ export async function importCharacterization(
): Promise<ApiResult<CharacterizationDefinition>> {
return unwrap(async () => {
const data = await httpPost<unknown>('/cohort-characterization/import', design)
return parseOrThrow(
const imported = parseOrThrow(
CharacterizationDefinitionSchema,
data,
'Invalid response from POST /cohort-characterization/import'
) as CharacterizationDefinition
await syncAccessAfterCreate('cohortCharacterization', imported.id)
return imported
}, CONTEXT)
}

Expand Down
11 changes: 7 additions & 4 deletions src/services/cohort-definition.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { logger } from '@/utils/logger'
import { httpGet, httpPost, httpPut, httpDelete, httpPostRead, getBaseUrl } from '@/services/http-client'
import { unwrap, ApiError, parseOrThrow, zodIssues } from '@/services/api-error'
import { syncAccessAfterCreate } from '@/services/auth/entityOwnership'
import { type ApiResult } from '@/types/api'
import type { RawCohortDefinition } from '@/models/atlas.types'
import type { CohortDefinition } from '@/models/cohort.types'
Expand Down Expand Up @@ -68,10 +69,12 @@ export async function saveCohortDefinition(
): Promise<ApiResult<CohortDefinition>> {
return unwrap(async () => {
logger.debug(CONTEXT, 'Saving cohort definition', { id: cohort.id, name: cohort.name })
const saved = cohort.id
? await httpPut<CohortDefinition>(`/cohortdefinition/${cohort.id}`, cohort)
: await httpPost<CohortDefinition>('/cohortdefinition', cohort)
return saved
if (cohort.id) {
return await httpPut<CohortDefinition>(`/cohortdefinition/${cohort.id}`, cohort)
}
const created = await httpPost<CohortDefinition>('/cohortdefinition', cohort)
await syncAccessAfterCreate('cohortDefinition', created.id)
return created
}, CONTEXT)
}

Expand Down
2 changes: 2 additions & 0 deletions src/services/concept-set.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
import { logger } from '@/utils/logger'
import { httpGet, httpPost, httpPut, httpDelete } from '@/services/http-client'
import { getSourceKey } from '@/config/webapi'
import { syncAccessAfterCreate } from '@/services/auth/entityOwnership'

/**
* Prefer the source key validated against the sources the server actually
Expand Down Expand Up @@ -104,6 +105,7 @@ export async function createConceptSet(
}

const data = await httpPost<ConceptSetAPIResponse>('/conceptset', metadataPayload)
await syncAccessAfterCreate('conceptSet', data.id)

if ((conceptSet.items?.length || 0) > 0 && data.id) {
const itemsPayload = (conceptSet.items || []).map(item => ({
Expand Down
13 changes: 11 additions & 2 deletions src/services/feature-analysis.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
type CovariateSetting,
} from '@/models/feature-analysis.types'
import { z } from 'zod'
import { syncAccessAfterCreate } from '@/services/auth/entityOwnership'

const CONTEXT = 'FeatureAnalysisService'

Expand Down Expand Up @@ -51,7 +52,13 @@ export async function createFeatureAnalysis(
): Promise<ApiResult<FeatureAnalysis>> {
return unwrap(async () => {
const data = await httpPost<unknown>('/feature-analysis', fa)
return parseOrThrow(FeatureAnalysisSchema, data, 'Invalid response from POST /feature-analysis') as FeatureAnalysis
const created = parseOrThrow(
FeatureAnalysisSchema,
data,
'Invalid response from POST /feature-analysis'
) as FeatureAnalysis
await syncAccessAfterCreate('feAnalysis', created.id)
return created
}, CONTEXT)
}

Expand Down Expand Up @@ -92,11 +99,13 @@ export async function deleteFeatureAnalysis(id: number): Promise<ApiResult<void>
export async function copyFeatureAnalysis(id: number): Promise<ApiResult<FeatureAnalysis>> {
return unwrap(async () => {
const data = await httpGet<unknown>(`/feature-analysis/${id}/copy`)
return parseOrThrow(
const copied = parseOrThrow(
FeatureAnalysisSchema,
data,
`Invalid response from /feature-analysis/${id}/copy`
) as FeatureAnalysis
await syncAccessAfterCreate('feAnalysis', copied.id)
return copied
}, CONTEXT)
}

Expand Down
13 changes: 10 additions & 3 deletions src/services/incidence-rate.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {
} from '@/models/incidence-rate.types'
import { z } from 'zod'
import { normalizeCriteriaGroupForCirce } from '@/components/cohort-editor/normalize'
import { syncAccessAfterCreate } from '@/services/auth/entityOwnership'

const CONTEXT = 'IncidenceRateService'

Expand Down Expand Up @@ -87,7 +88,9 @@ export async function getIncidenceRate(id: number): Promise<ApiResult<IncidenceR
export async function createIncidenceRate(ir: IncidenceRate): Promise<ApiResult<IncidenceRate>> {
return unwrap(async () => {
const data = await httpPost<unknown>('/ir/', encodeIRForSave(ir))
return decodeIRExpression(data)
const created = decodeIRExpression(data)
await syncAccessAfterCreate('incidenceRate', created.id)
return created
}, CONTEXT)
}

Expand All @@ -106,7 +109,9 @@ export async function saveIncidenceRate(
export async function copyIncidenceRate(id: number): Promise<ApiResult<IncidenceRate>> {
return unwrap(async () => {
const data = await httpGet<unknown>(`/ir/${id}/copy`)
return decodeIRExpression(data)
const copied = decodeIRExpression(data)
await syncAccessAfterCreate('incidenceRate', copied.id)
return copied
}, CONTEXT)
}

Expand Down Expand Up @@ -144,7 +149,9 @@ export async function exportIncidenceRate(id: number): Promise<unknown> {
*/
export async function importIncidenceRate(design: unknown): Promise<IncidenceRate> {
const data = await httpPost<unknown>('/ir/design', design)
return decodeIRExpression(data)
const imported = decodeIRExpression(data)
await syncAccessAfterCreate('incidenceRate', imported.id)
return imported
}

/** POST /ir/{id}/tag/{tagId}. */
Expand Down
21 changes: 18 additions & 3 deletions src/services/pathway.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/
import { httpGet, httpPost, httpPostRead, httpPut, httpDelete } from '@/services/http-client'
import { unwrap, ApiError, parseOrThrow } from '@/services/api-error'
import { syncAccessAfterCreate } from '@/services/auth/entityOwnership'
import { type ApiResult } from '@/types/api'
import { logger } from '@/utils/logger'
import {
Expand Down Expand Up @@ -62,7 +63,13 @@ export async function getPathway(id: number): Promise<ApiResult<Pathway>> {
export async function createPathway(pathway: Pathway): Promise<ApiResult<Pathway>> {
return unwrap(async () => {
const data = await httpPost<unknown>('/pathway-analysis', pathway)
return parseOrThrow(PathwaySchema.passthrough(), data, 'Invalid create response') as Pathway
const created = parseOrThrow(
PathwaySchema.passthrough(),
data,
'Invalid create response'
) as Pathway
await syncAccessAfterCreate('pathway', created.id)
return created
}, CONTEXT)
}

Expand All @@ -84,7 +91,13 @@ export async function savePathway(id: number, pathway: Pathway): Promise<ApiResu
export async function copyPathway(id: number): Promise<ApiResult<Pathway>> {
return unwrap(async () => {
const data = await httpPost<unknown>(`/pathway-analysis/${id}`, undefined)
return parseOrThrow(PathwaySchema.passthrough(), data, 'Invalid copy response') as Pathway
const copied = parseOrThrow(
PathwaySchema.passthrough(),
data,
'Invalid copy response'
) as Pathway
await syncAccessAfterCreate('pathway', copied.id)
return copied
}, CONTEXT)
}

Expand Down Expand Up @@ -136,7 +149,9 @@ export async function importPathway(design: unknown): Promise<Pathway> {
logger.error('Pathway', 'importPathway validation', parsed.error)
throw new Error('Invalid response from POST /pathway-analysis/import')
}
return parsed.data as Pathway
const imported = parsed.data as Pathway
await syncAccessAfterCreate('pathway', imported.id)
return imported
}

/**
Expand Down
40 changes: 39 additions & 1 deletion src/stores/auth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import type { AuthState, UserInfo } from '@/models/auth.types'
import type { AuthState, EntityAccessKind, UserInfo } from '@/models/auth.types'
import { emptyEntityAccess } from '@/models/auth.types'
import { storageManager } from '@/services/auth/storageManager'
import { tokenManager } from '@/services/auth/tokenManager'
Expand Down Expand Up @@ -106,6 +106,44 @@ export const useAuthStore = defineStore('auth', {
}
},

/**
* Record the current user as owner of an entity they just created.
*
* The per-entity grant maps come from `/user/me`, which is only fetched at
* startup. Without this, a freshly created entity has no grant until the
* next page load, so `useEntityAccess` denies write and the editor's
* Save/Delete actions stay disabled on the thing the user just made.
*/
registerOwnedEntity(kind: EntityAccessKind, id: string | number | null | undefined) {
if (id === null || id === undefined || id === '') return
const key = String(id)
const map = this.entityAccess[kind]
if (map[key]?.isOwner) return
map[key] = { accessTypes: ['READ', 'WRITE'], isOwner: true }
},

/**
* Re-read the current subject's authorization snapshot from the server.
*
* A write can change more grants than the client can predict: importing a
* design creates cohorts and concept sets of its own, and the creator's
* grant on any of them only exists server-side. Rather than guess, ask
* `/user/me` again and let the answer replace what is held locally.
*
* Returns whether the refresh landed, so callers can keep an optimistic
* grant in place when the server could not be reached.
*/
async refreshUserContext(): Promise<boolean> {
try {
const { authService } = await import('@/services/auth/authService')
this.setUser(await authService.fetchUserInfo())
return true
} catch (error) {
logger.warn('Auth', 'Failed to refresh the user authorization snapshot', error)
return false
}
},

setAuthProvider(provider: string | null) {
this.authProvider = provider
},
Expand Down
Loading
Loading