Skip to content
Merged
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
7 changes: 6 additions & 1 deletion controller/src/classes/OdrlController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ export class ODRLController<T extends Record<keyof T, BaseSubject<keyof T & stri
return resolver.toLabel(subject);
}

async getResources(): Promise<string[]> {
return new ODRLPolicyService(this.authorizationServerURL).fetchResources();
}


/**
* Updates existing policies according to present rule changes
* @param updates All requested changes
Expand Down Expand Up @@ -164,6 +169,6 @@ export class ODRLController<T extends Record<keyof T, BaseSubject<keyof T & stri
asRequestingParty: AccessRequestObject[];
asResourceOwner: AccessRequestObject[];
}> {
return new ODRLAccessRequestService(this.authorizationServerURL).retrieveAccessRequests();
return new ODRLAccessRequestService(this.authorizationServerURL).retrieveAccessRequests(await this.getResources());
}
}
30 changes: 9 additions & 21 deletions controller/src/classes/utils/OdrlAccessRequestService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,33 +85,25 @@ export class ODRLAccessRequestService {
/**
* Retrieve all access requests related to the given resource owner or requesting party
*/
public retrieveAccessRequests = async (): Promise<{ asRequestingParty: AccessRequest[], asResourceOwner: AccessRequest[] }> => {
const [ requestsResponse, policiesResponse ] = await Promise.all(
['/requests', '/policies'].map((endpoint) => authenticatedFetch(
`${this.authorizationServerURL}${endpoint}`, {
method: 'GET',
}
))
);
public retrieveAccessRequests = async (owned: string[]): Promise<{ asRequestingParty: AccessRequest[], asResourceOwner: AccessRequest[] }> => {
const requestsResponse = await authenticatedFetch(`${this.authorizationServerURL}/requests`);

if (requestsResponse.status === 404) return {
asRequestingParty: [],
asResourceOwner: []
}

const requestsText = await requestsResponse.text() || '';
const policiesText = await policiesResponse.text() || '';

const requestsStore = new Store(this.parser.parse(requestsText));
const policiesStore = new Store(this.parser.parse(policiesText));

const id = getLoggedInIdentifier();
const requestingPartyBindings = await this.queryEngine.queryBindings(
this.accessRequestForRequestingParty(id), { sources: [requestsStore] }
);

const resourceOwnerBindings = await this.queryEngine.queryBindings(
this.accessRequestForResourceOwner(id), { sources: [requestsStore, policiesStore] }
this.accessRequestForResourceOwner(owned), { sources: [requestsStore] }
);

return {
Expand Down Expand Up @@ -207,10 +199,12 @@ export class ODRLAccessRequestService {
(GROUP_CONCAT(DISTINCT ?action; separator=",") AS ?actions)
?constraintUri ?leftOperand ?operator ?rightOperand
WHERE {
VALUES ?requestingParty { <${requestingPartyID}> }

?uid a sotw:EvaluationRequest ;
sotw:requestedTarget ?target ;
sotw:requestedAction ?action ;
sotw:requestingParty <${requestingPartyID}> ;
sotw:requestingParty ?requestingParty ;
sotw:requestStatus ?status .

OPTIONAL {
Expand All @@ -228,14 +222,9 @@ export class ODRLAccessRequestService {
* Fetches all access requests controlled by a given WebId
* Returns a SPARQL query string
*
* An ID being the resource owner is determined by there being a policy owned by this ID targeting this resource.
* If there is no policy yet for this resource,
* this function will not be able to determine that the given ID is the owner.
*
* @param resourceOwnerID
* @returns
*/
private readonly accessRequestForResourceOwner = (resourceOwnerID: string): string => `
private readonly accessRequestForResourceOwner = (owned: string[]): string => `
PREFIX ex: <http://example.org/>
PREFIX sotw: <https://w3id.org/force/sotw#>
PREFIX odrl: <http://www.w3.org/ns/odrl/2/>
Expand All @@ -244,9 +233,8 @@ export class ODRLAccessRequestService {
(GROUP_CONCAT(DISTINCT ?action; separator=",") AS ?actions)
?constraintUri ?leftOperand ?operator ?rightOperand
WHERE {
?policy odrl:target ?target ;
odrl:assigner <${resourceOwnerID}> .

VALUES ?target { ${owned.map(o => `<${o}>`).join(' ')} }

?uid a sotw:EvaluationRequest ;
sotw:requestedTarget ?target ;
sotw:requestedAction ?action ;
Expand Down
13 changes: 13 additions & 0 deletions controller/src/classes/utils/OdrlPolicyService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,19 @@ export class ODRLPolicyService {
return result;
}

public async fetchResources(): Promise<string[]> {
// TODO: should use well-known URL
const response = await authenticatedFetch(`${this.authorizationServerURL}/resources/`);
if (!response.ok) {
throw new Error(response.statusText);
}
const data = await response.json();
if (!Array.isArray(data)) {
throw new Error('Expected an array of resources');
}
return data;
}

public async fetchPolicies() {
// Get all our policies
const response = await authenticatedFetch(UMA_URL(this.authorizationServerURL), {
Expand Down
5 changes: 5 additions & 0 deletions controller/src/types/modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ export interface IController<T extends Record<keyof T, BaseSubject<keyof T & str
getLabelForSubject<K extends SubjectKey<T>>(subject: T[K]): string;
getOrCreateIndex(): Promise<Index>;

/**
* Returns all resources registered for the authorized user.
*/
getResources(): Promise<string[]>;

updatePolicy(updates: RuleUpdate[]): Promise<void>;
getResourcePolicies(resourceUrl: string): Promise<Policy[]>;

Expand Down
25 changes: 22 additions & 3 deletions loama/src/components/policy-view/RuleForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
placeholder="webId of the person or app" />

<label :for="`resource`">Resource</label>
<input :id="`resource`" v-model="form.resourceIdentifier" :disabled="!editable"
:class="{ error: errors.resourceIdentifier }" placeholder="resource url" />
<select v-if="resourcesLoaded" :id="`resource`" ref="resourceSelectEl"
:class="{ error: errors.resourceIdentifier }"></select>
<input v-else :id="`resource`" value="Loading resources..." disabled />


<span class="field-label">Access level</span>
Expand Down Expand Up @@ -58,7 +59,7 @@ import { computed, onMounted, reactive, ref, watch } from 'vue';
import type { Rule, Constraint, RuleUpdate } from 'loama-controller';
import { levelForAction } from '@/lib/Accesslevel';
import { loadPurposes, PURPOSES } from '@/lib/Purposes';
import { useTomSelectMultiple } from '@/lib/Usetomselect'
import { useTomSelectMultiple, useTomSelectSingle } from '@/lib/Usetomselect'
import { usePodStore } from '@/lib/state';
import { useControllerStore } from '@/stores/useControllerStore';
import 'tom-select/dist/css/tom-select.css';
Expand Down Expand Up @@ -96,6 +97,7 @@ const form = reactive({

const errors = ref({ resourceIdentifier: false, action: false });
const confirmingDelete = ref(false);
const resourcesLoaded = ref(false);
const purposesLoaded = ref(false);

watch(() => form.action.length, (newLength) => {
Expand All @@ -114,12 +116,27 @@ const subjectEditable = computed(() => {
return editable.value && selectedPolicy.value?.type !== 'Agreement';
});

const resourceSelectEl = ref<HTMLSelectElement | null>(null);
const resourceModel = computed<string>({
get: () => form.resourceIdentifier,
set: (value) => { form.resourceIdentifier = value; },
});

const purposeSelectEl = ref<HTMLSelectElement | null>(null);
const purposesModel = computed<string[]>({
get: () => form.purposes,
set: (value) => { form.purposes = value; },
});

useTomSelectSingle(resourceSelectEl, resourceModel, editable, () => ({
options: podStore.resources.map((r) => ({ value: r, text: r })),
valueField: 'value',
labelField: 'text',
searchField: ['text'],
placeholder: 'Search resources…',
create: false,
}));

useTomSelectMultiple(purposeSelectEl, purposesModel, editable, {
options: PURPOSES.options,
optgroups: PURPOSES.groups,
Expand Down Expand Up @@ -200,6 +217,8 @@ watch(() => [props.rule, props.mode], resetForm, { immediate: true });
onMounted(async () => {
await loadPurposes();
purposesLoaded.value = true;
await podStore.loadResources(controllerStore.current);
resourcesLoaded.value = true;
resetForm();
});

Expand Down
99 changes: 86 additions & 13 deletions loama/src/lib/Usetomselect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ import TomSelect from 'tom-select';
* @param settings TomSelect settings (options, optgroups, render, etc.)
*/
export function useTomSelectMultiple(
elRef: Ref<HTMLSelectElement | null>,
modelValue: Ref<string[]>,
editable: Ref<boolean>,
settings: Record<string, unknown> = {},
elRef: Ref<HTMLSelectElement | null>,
modelValue: Ref<string[]>,
editable: Ref<boolean>,
settings: Record<string, unknown> = {},
) {
let instance: TomSelect | null = null;

Expand All @@ -24,8 +24,8 @@ export function useTomSelectMultiple(
const current = instance.getValue();
const currentArray = Array.isArray(current) ? current : [current];
const same =
currentArray.length === value.length &&
currentArray.every((v, i) => v === value[i]);
currentArray.length === value.length &&
currentArray.every((v, i) => v === value[i]);
if (!same) instance.setValue(value, true); // true = silent, avoids feedback loop
};

Expand Down Expand Up @@ -59,16 +59,89 @@ export function useTomSelectMultiple(
// that starts false), so react to the ref itself rather than only
// creating the instance once in onMounted.
watch(
elRef,
(el) => {
destroy();
if (el) create(el);
},
{ immediate: true },
elRef,
(el) => {
destroy();
if (el) create(el);
},
{ immediate: true },
);

watch(modelValue, applyValue, { deep: true });
watch(editable, applyEditable);

onBeforeUnmount(destroy);
}
}

/**
* Same idea as useTomSelectMultiple, but binds to a plain <select> (no
* `multiple`) and keeps it in sync with a reactive string model instead of
* a string[] model.
*
* @param elRef template ref to the underlying <select> element
* @param modelValue reactive single selected value, kept in sync both ways
* @param editable reactive boolean, toggles TomSelect enable/disable
* @param settings TomSelect settings (options, optgroups, render, etc.)
*/
export function useTomSelectSingle(
elRef: Ref<HTMLSelectElement | null>,
modelValue: Ref<string>,
editable: Ref<boolean>,
settings: Record<string, unknown> | (() => Record<string, unknown>) = {},
) {
let instance: TomSelect | null = null;

// Support a factory so option lists reflect data that's still loading
// (e.g. async store data) at the time useTomSelectSingle() is called,
// rather than whatever snapshot existed during <script setup>.
const resolveSettings = () =>
typeof settings === 'function' ? settings() : settings;

const applyValue = (value: string) => {
if (!instance) return;
const current = instance.getValue();
// Single-select TomSelect returns a plain string, but guard against
// an array just in case a caller's settings force list behavior.
const currentValue = Array.isArray(current) ? current[0] ?? '' : current;
if (currentValue !== value) instance.setValue(value, true); // true = silent, avoids feedback loop
};

const applyEditable = (isEditable: boolean) => {
if (!instance) return;
if (isEditable) instance.enable();
else instance.disable();
};

const create = (el: HTMLSelectElement) => {
instance = new TomSelect(el, {
maxItems: 1,
...resolveSettings(),
});

instance.on('change', (value: string) => {
modelValue.value = value;
});

applyValue(modelValue.value);
applyEditable(editable.value);
};

const destroy = () => {
instance?.destroy();
instance = null;
};

watch(
elRef,
(el) => {
destroy();
if (el) create(el);
},
{ immediate: true },
);

watch(modelValue, applyValue);
watch(editable, applyEditable);

onBeforeUnmount(destroy);
}
7 changes: 6 additions & 1 deletion loama/src/lib/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,25 @@ import { defineStore } from "pinia";
type PodStore = {
policies: Policy[];
selectedEntry: Policy | null;
resources: string[];
}

export const usePodStore = defineStore("pod", {
state: (): PodStore => ({
selectedEntry: null,
policies: [],
resources: [],
}),
actions: {
async loadResources(controller: IController<{webId: WebIdSubject; public: PublicSubject;}>) {
this.resources = await controller.getResources();
},
async loadPolicies(url: string, controller: IController<{webId: WebIdSubject; public: PublicSubject;}>) {
this.policies = await controller.getResourcePolicies(url);
},
async updatePolicy(ruleUpdates: RuleUpdate[], controller: IController<{webId: WebIdSubject; public: PublicSubject;}>) {
await controller.updatePolicy(ruleUpdates);

this.policies = await controller.getResourcePolicies("");
},
}
Expand Down