@axium/client 0.12.3 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/animations.css +30 -0
- package/assets/styles.css +17 -26
- package/dist/access.d.ts +4 -2
- package/dist/access.js +8 -2
- package/dist/requests.js +6 -1
- package/lib/AccessControlDialog.svelte +77 -42
- package/lib/UserDiscovery.svelte +144 -0
- package/lib/ZodInput.svelte +0 -15
- package/package.json +2 -2
- package/styles/list.css +0 -1
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
@keyframes zoom {
|
|
2
|
+
from {
|
|
3
|
+
transform: scale(0.95);
|
|
4
|
+
}
|
|
5
|
+
to {
|
|
6
|
+
transform: scale(1);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
@keyframes fade {
|
|
11
|
+
from {
|
|
12
|
+
opacity: 0;
|
|
13
|
+
}
|
|
14
|
+
to {
|
|
15
|
+
opacity: 1;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
:root {
|
|
20
|
+
--A-zoom: zoom 0.25s cubic-bezier(0.35, 1.55, 0.65, 1);
|
|
21
|
+
--A-fade: fade 0.25s ease-out;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
dialog[open] {
|
|
25
|
+
animation: var(--A-zoom);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
dialog[open]::backdrop {
|
|
29
|
+
animation: var(--A-fade);
|
|
30
|
+
}
|
package/assets/styles.css
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
@import './animations.css';
|
|
2
|
+
|
|
1
3
|
* {
|
|
2
4
|
box-sizing: border-box;
|
|
3
5
|
color: hsl(0 0 var(--fg-light));
|
|
@@ -65,6 +67,21 @@ textarea {
|
|
|
65
67
|
outline: none;
|
|
66
68
|
}
|
|
67
69
|
|
|
70
|
+
label.checkbox {
|
|
71
|
+
cursor: pointer;
|
|
72
|
+
width: 1.5em;
|
|
73
|
+
height: 1.5em;
|
|
74
|
+
border: 1px solid var(--border-accent);
|
|
75
|
+
border-radius: 0.5em;
|
|
76
|
+
display: inline-flex;
|
|
77
|
+
justify-content: center;
|
|
78
|
+
align-items: center;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
input[type='checkbox'] {
|
|
82
|
+
display: none;
|
|
83
|
+
}
|
|
84
|
+
|
|
68
85
|
select,
|
|
69
86
|
::picker(select) {
|
|
70
87
|
appearance: base-select;
|
|
@@ -118,32 +135,6 @@ dialog::backdrop {
|
|
|
118
135
|
background: #0003;
|
|
119
136
|
}
|
|
120
137
|
|
|
121
|
-
dialog[open] {
|
|
122
|
-
animation: zoom 0.25s cubic-bezier(0.35, 1.55, 0.65, 1);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
@keyframes zoom {
|
|
126
|
-
from {
|
|
127
|
-
transform: scale(0.95);
|
|
128
|
-
}
|
|
129
|
-
to {
|
|
130
|
-
transform: scale(1);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
dialog[open]::backdrop {
|
|
135
|
-
animation: fade 0.25s ease-out;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
@keyframes fade {
|
|
139
|
-
from {
|
|
140
|
-
opacity: 0;
|
|
141
|
-
}
|
|
142
|
-
to {
|
|
143
|
-
opacity: 1;
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
138
|
dialog form {
|
|
148
139
|
display: contents;
|
|
149
140
|
}
|
package/dist/access.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
import type { AccessControl,
|
|
2
|
-
export declare function
|
|
1
|
+
import type { AccessControl, AccessTarget } from '@axium/core';
|
|
2
|
+
export declare function updateACL(itemType: string, itemId: string, target: AccessTarget, permissions: Partial<Record<string, any>>): Promise<AccessControl>;
|
|
3
3
|
export declare function getACL(itemType: string, itemId: string): Promise<AccessControl[]>;
|
|
4
|
+
export declare function addToACL(itemType: string, itemId: string, target: AccessTarget): Promise<AccessControl>;
|
|
5
|
+
export declare function removeFromACL(itemType: string, itemId: string, target: AccessTarget): Promise<AccessControl>;
|
package/dist/access.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { fetchAPI } from './requests.js';
|
|
2
|
-
export async function
|
|
3
|
-
return await fetchAPI('
|
|
2
|
+
export async function updateACL(itemType, itemId, target, permissions) {
|
|
3
|
+
return await fetchAPI('PATCH', 'acl/:itemType/:itemId', { target, permissions }, itemType, itemId);
|
|
4
4
|
}
|
|
5
5
|
export async function getACL(itemType, itemId) {
|
|
6
6
|
return await fetchAPI('GET', 'acl/:itemType/:itemId', {}, itemType, itemId);
|
|
7
7
|
}
|
|
8
|
+
export async function addToACL(itemType, itemId, target) {
|
|
9
|
+
return await fetchAPI('PUT', 'acl/:itemType/:itemId', target, itemType, itemId);
|
|
10
|
+
}
|
|
11
|
+
export async function removeFromACL(itemType, itemId, target) {
|
|
12
|
+
return await fetchAPI('DELETE', 'acl/:itemType/:itemId', target, itemType, itemId);
|
|
13
|
+
}
|
package/dist/requests.js
CHANGED
|
@@ -49,6 +49,11 @@ export async function fetchAPI(method, endpoint, data, ...params) {
|
|
|
49
49
|
const json = await response.json().catch(() => ({ message: 'Unknown server error (invalid JSON response)' }));
|
|
50
50
|
if (!response.ok)
|
|
51
51
|
throw new Error(json.message);
|
|
52
|
+
if (typeof json == 'object' && json != null && '_warnings' in json) {
|
|
53
|
+
for (const warning of json._warnings)
|
|
54
|
+
console.warn('[API]', warning);
|
|
55
|
+
delete json._warnings;
|
|
56
|
+
}
|
|
52
57
|
if (!schema)
|
|
53
58
|
return json;
|
|
54
59
|
const Output = Array.isArray(schema) ? schema[1] : schema;
|
|
@@ -56,6 +61,6 @@ export async function fetchAPI(method, endpoint, data, ...params) {
|
|
|
56
61
|
return Output.parse(json);
|
|
57
62
|
}
|
|
58
63
|
catch (e) {
|
|
59
|
-
throw prettifyError(e)
|
|
64
|
+
throw `${method} ${endpoint}:\n${prettifyError(e)}`;
|
|
60
65
|
}
|
|
61
66
|
}
|
|
@@ -1,43 +1,46 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { getACL,
|
|
2
|
+
import { addToACL, getACL, updateACL } from '@axium/client/access';
|
|
3
3
|
import { userInfo } from '@axium/client/user';
|
|
4
|
-
import type {
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
7
|
-
import UserCard from './UserCard.svelte';
|
|
8
|
-
import Icon from './Icon.svelte';
|
|
4
|
+
import type { AccessControllable, AccessTarget, User } from '@axium/core';
|
|
5
|
+
import { getTarget, pickPermissions } from '@axium/core/access';
|
|
6
|
+
import { errorText } from '@axium/core/io';
|
|
9
7
|
import type { HTMLDialogAttributes } from 'svelte/elements';
|
|
8
|
+
import Icon from './Icon.svelte';
|
|
9
|
+
import UserCard from './UserCard.svelte';
|
|
10
|
+
import UserDiscovery from './UserDiscovery.svelte';
|
|
10
11
|
|
|
11
12
|
interface Props extends HTMLDialogAttributes {
|
|
12
13
|
editable: boolean;
|
|
13
14
|
dialog?: HTMLDialogElement;
|
|
14
15
|
itemType: string;
|
|
15
|
-
item
|
|
16
|
-
acl?: AccessControl[];
|
|
16
|
+
item: { name?: string; user?: User; id: string } & AccessControllable;
|
|
17
17
|
}
|
|
18
|
-
let { item
|
|
18
|
+
let { item, itemType, editable, dialog = $bindable(), ...rest }: Props = $props();
|
|
19
|
+
|
|
20
|
+
let error = $state<string>();
|
|
21
|
+
|
|
22
|
+
const acl = $state(item.acl ?? (await getACL(itemType, item.id)));
|
|
19
23
|
|
|
20
|
-
|
|
24
|
+
async function onSelect(target: AccessTarget) {
|
|
25
|
+
const control = await addToACL(itemType, item.id, target);
|
|
26
|
+
if (control.userId) control.user = await userInfo(control.userId);
|
|
27
|
+
acl.push(control);
|
|
28
|
+
}
|
|
21
29
|
</script>
|
|
22
30
|
|
|
23
|
-
<
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
<h3>Permissions for <strong>{item.name}</strong></h3>
|
|
34
|
-
{:else}
|
|
35
|
-
<h3>Permissions</h3>
|
|
36
|
-
{/if}
|
|
37
|
-
{/snippet}
|
|
31
|
+
<dialog bind:this={dialog} {...rest}>
|
|
32
|
+
{#if item.name}
|
|
33
|
+
<h3>Permissions for <strong>{item.name}</strong></h3>
|
|
34
|
+
{:else}
|
|
35
|
+
<h3>Permissions</h3>
|
|
36
|
+
{/if}
|
|
37
|
+
|
|
38
|
+
{#if error}
|
|
39
|
+
<div class="error">{error}</div>
|
|
40
|
+
{/if}
|
|
38
41
|
|
|
39
42
|
<div class="AccessControl">
|
|
40
|
-
{#if item
|
|
43
|
+
{#if item.user}
|
|
41
44
|
<UserCard user={item.user} />
|
|
42
45
|
{:else if item}
|
|
43
46
|
{#await userInfo(item.userId) then user}<UserCard {user} />{/await}
|
|
@@ -45,7 +48,15 @@
|
|
|
45
48
|
<span>Owner</span>
|
|
46
49
|
</div>
|
|
47
50
|
|
|
48
|
-
{#each acl
|
|
51
|
+
{#each acl as control}
|
|
52
|
+
{@const update = (key: string) => async (e: Event & { currentTarget: HTMLInputElement }) => {
|
|
53
|
+
try {
|
|
54
|
+
const updated = await updateACL(itemType, item.id, getTarget(control), { [key]: e.currentTarget.checked });
|
|
55
|
+
Object.assign(control, updated);
|
|
56
|
+
} catch (e) {
|
|
57
|
+
error = errorText(e);
|
|
58
|
+
}
|
|
59
|
+
}}
|
|
49
60
|
<div class="AccessControl">
|
|
50
61
|
{#if control.user}
|
|
51
62
|
<UserCard user={control.user} />
|
|
@@ -58,25 +69,49 @@
|
|
|
58
69
|
{:else}
|
|
59
70
|
<i>Unknown</i>
|
|
60
71
|
{/if}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
{
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
<div class="permissions">
|
|
73
|
+
{#each Object.entries(pickPermissions(control) as Record<string, boolean>) as [key, value]}
|
|
74
|
+
{@const id = `${getTarget(control)}.${key}`}
|
|
75
|
+
<span class="icon-text">
|
|
76
|
+
{#if editable}
|
|
77
|
+
<input {id} type="checkbox" onchange={update(key)} />
|
|
78
|
+
<label for={id} class="checkbox">
|
|
79
|
+
{#if value}<Icon i="check" --size="1.3em" />{/if}
|
|
80
|
+
</label>
|
|
81
|
+
{:else}
|
|
82
|
+
<Icon i={value ? 'check' : 'xmark'} />
|
|
83
|
+
{/if}
|
|
84
|
+
<span>{key}</span>
|
|
85
|
+
</span>
|
|
86
|
+
{/each}
|
|
87
|
+
</div>
|
|
75
88
|
</div>
|
|
76
89
|
{/each}
|
|
77
|
-
|
|
90
|
+
|
|
91
|
+
<UserDiscovery {onSelect} excludeTargets={acl.map(getTarget)} />
|
|
92
|
+
|
|
93
|
+
<div>
|
|
94
|
+
<button class="done" onclick={() => dialog!.close()}>Done</button>
|
|
95
|
+
</div>
|
|
96
|
+
</dialog>
|
|
78
97
|
|
|
79
98
|
<style>
|
|
99
|
+
dialog:open {
|
|
100
|
+
display: flex;
|
|
101
|
+
flex-direction: column;
|
|
102
|
+
gap: 1em;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.done {
|
|
106
|
+
float: right;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.permissions {
|
|
110
|
+
display: flex;
|
|
111
|
+
flex-direction: column;
|
|
112
|
+
gap: 0.1em;
|
|
113
|
+
}
|
|
114
|
+
|
|
80
115
|
.AccessControl {
|
|
81
116
|
display: grid;
|
|
82
117
|
gap: 1em;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { fetchAPI } from '@axium/client/requests';
|
|
3
|
+
import { getUserImage, type AccessTarget, type UserPublic } from '@axium/core';
|
|
4
|
+
import { colorHash } from '@axium/core/color';
|
|
5
|
+
import Icon from './Icon.svelte';
|
|
6
|
+
import { errorText } from '@axium/core/io';
|
|
7
|
+
|
|
8
|
+
const {
|
|
9
|
+
onSelect,
|
|
10
|
+
enableTags = false,
|
|
11
|
+
excludeTargets = [],
|
|
12
|
+
}: {
|
|
13
|
+
onSelect(target: AccessTarget): unknown;
|
|
14
|
+
enableTags?: boolean;
|
|
15
|
+
excludeTargets?: string[];
|
|
16
|
+
} = $props();
|
|
17
|
+
|
|
18
|
+
type Result = { type: 'user'; value: UserPublic; target: string } | { type: 'role' | 'tag'; value: string; target: string };
|
|
19
|
+
|
|
20
|
+
let results = $state<Result[]>([]);
|
|
21
|
+
let value = $state<string>();
|
|
22
|
+
let gotError = $state<boolean>(false);
|
|
23
|
+
|
|
24
|
+
async function onchange() {
|
|
25
|
+
if (!value || !value.length) {
|
|
26
|
+
results = [];
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const users = await fetchAPI('POST', 'users/discover', value);
|
|
32
|
+
results = [
|
|
33
|
+
...users.map(value => ({ type: 'user', value, target: value.id }) as const),
|
|
34
|
+
{ type: 'role', value, target: '@' + value } as const,
|
|
35
|
+
enableTags && ({ type: 'tag', value, target: '#' + value } as const),
|
|
36
|
+
].filter<Result>(r => !!r);
|
|
37
|
+
} catch (e) {
|
|
38
|
+
gotError = true;
|
|
39
|
+
console.warn('Can not use user discovery:', errorText(e));
|
|
40
|
+
results = [];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function select(target: string) {
|
|
45
|
+
return (e: Event) => {
|
|
46
|
+
e.stopPropagation();
|
|
47
|
+
onSelect(target);
|
|
48
|
+
results = [];
|
|
49
|
+
value = '';
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<input bind:value type="text" placeholder="Add users and roles" {onchange} onkeyup={onchange} />
|
|
55
|
+
{#if !gotError}
|
|
56
|
+
<!-- Don't show results when we can't use the discovery API -->
|
|
57
|
+
<div class="results">
|
|
58
|
+
{#each results as result}
|
|
59
|
+
{#if !excludeTargets.includes(result.target)}
|
|
60
|
+
<div class="result" onclick={select(result.target)}>
|
|
61
|
+
{#if result.type == 'user'}
|
|
62
|
+
<span><img src={getUserImage(result.value)} alt={result.value.name} />{result.value.name}</span>
|
|
63
|
+
{:else if result.type == 'role'}
|
|
64
|
+
<span>
|
|
65
|
+
<span class="icon-text tag-or-role" style:background-color={colorHash(result.value)}
|
|
66
|
+
><Icon i="at" />{result.value}</span
|
|
67
|
+
>
|
|
68
|
+
</span>
|
|
69
|
+
{:else if result.type == 'tag'}
|
|
70
|
+
<span>
|
|
71
|
+
<span class="icon-text tag-or-role" style:background-color={colorHash(result.value)}
|
|
72
|
+
><Icon i="hashtag" />{result.value}</span
|
|
73
|
+
>
|
|
74
|
+
</span>
|
|
75
|
+
{/if}
|
|
76
|
+
</div>
|
|
77
|
+
{/if}
|
|
78
|
+
{:else}
|
|
79
|
+
<i>No results</i>
|
|
80
|
+
{/each}
|
|
81
|
+
</div>
|
|
82
|
+
{/if}
|
|
83
|
+
|
|
84
|
+
<style>
|
|
85
|
+
:host {
|
|
86
|
+
anchor-scope: --discovery-input;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
input {
|
|
90
|
+
anchor-name: --discovery-input;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
input:focus + .results,
|
|
94
|
+
.results:active {
|
|
95
|
+
display: flex;
|
|
96
|
+
animation: var(--A-zoom);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.results {
|
|
100
|
+
position: fixed;
|
|
101
|
+
position-anchor: --discovery-input;
|
|
102
|
+
inset: anchor(bottom) anchor(right) auto anchor(left);
|
|
103
|
+
display: none;
|
|
104
|
+
flex-direction: column;
|
|
105
|
+
gap: 0.25em;
|
|
106
|
+
height: fit-content;
|
|
107
|
+
max-height: 25em;
|
|
108
|
+
background-color: var(--bg-accent);
|
|
109
|
+
border-radius: 0.25em 0.25em 0.75em 0.75em;
|
|
110
|
+
padding: 1em;
|
|
111
|
+
border: 1px solid var(--border-accent);
|
|
112
|
+
align-items: stretch;
|
|
113
|
+
|
|
114
|
+
i {
|
|
115
|
+
text-align: center;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.result {
|
|
120
|
+
padding: 0.5em;
|
|
121
|
+
border-radius: 0.5em;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.result:hover {
|
|
125
|
+
cursor: pointer;
|
|
126
|
+
background-color: var(--bg-strong);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.tag-or-role {
|
|
130
|
+
border-radius: 1em;
|
|
131
|
+
padding: 0.25em 0.75em;
|
|
132
|
+
display: inline-flex;
|
|
133
|
+
align-items: center;
|
|
134
|
+
gap: 0.25em;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
img {
|
|
138
|
+
width: 2em;
|
|
139
|
+
height: 2em;
|
|
140
|
+
border-radius: 50%;
|
|
141
|
+
vertical-align: middle;
|
|
142
|
+
margin-right: 0.5em;
|
|
143
|
+
}
|
|
144
|
+
</style>
|
package/lib/ZodInput.svelte
CHANGED
|
@@ -204,21 +204,6 @@
|
|
|
204
204
|
{/if}
|
|
205
205
|
|
|
206
206
|
<style>
|
|
207
|
-
input[type='checkbox'] {
|
|
208
|
-
display: none;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
label.checkbox {
|
|
212
|
-
cursor: pointer;
|
|
213
|
-
width: 1.5em;
|
|
214
|
-
height: 1.5em;
|
|
215
|
-
border: 1px solid var(--border-accent);
|
|
216
|
-
border-radius: 0.5em;
|
|
217
|
-
display: inline-flex;
|
|
218
|
-
justify-content: center;
|
|
219
|
-
align-items: center;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
207
|
.ZodInput-error {
|
|
223
208
|
position: fixed;
|
|
224
209
|
position-anchor: --zod-input;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@axium/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"author": "James Prevett <jp@jamespre.dev>",
|
|
5
5
|
"funding": {
|
|
6
6
|
"type": "individual",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"build": "tsc"
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
43
|
-
"@axium/core": ">=0.
|
|
43
|
+
"@axium/core": ">=0.19.0",
|
|
44
44
|
"utilium": "^2.3.8",
|
|
45
45
|
"zod": "^4.0.5",
|
|
46
46
|
"svelte": "^5.36.0"
|