Compare commits
749 Commits
codex/dial
...
deploy/dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd6b4a4897 | ||
|
|
bcfb0a32d2 | ||
|
|
561c935415 | ||
|
|
e003cc2918 | ||
|
|
f99d2b9434 | ||
|
|
0cae6dd813 | ||
|
|
4ba0e68e56 | ||
|
|
c0c658eef4 | ||
|
|
0bfeb23852 | ||
|
|
0c4074fc77 | ||
|
|
920410fda5 | ||
|
|
968694045d | ||
|
|
6d8d36ce06 | ||
|
|
aba44b75a6 | ||
|
|
e01112cf6a | ||
|
|
dc42623bc0 | ||
|
|
690d30fbbc | ||
|
|
f6be752b5b | ||
|
|
625c595409 | ||
|
|
0c72432ebc | ||
|
|
b0ae7744a1 | ||
|
|
95674ae71e | ||
|
|
7e27f10ea7 | ||
|
|
0bd09d5b7a | ||
|
|
ba7df082bc | ||
|
|
0fb011126e | ||
|
|
06d45f7c09 | ||
|
|
0047772f15 | ||
|
|
0584b26883 | ||
|
|
b10d9aedd8 | ||
|
|
ebccd86338 | ||
|
|
aa40a404d9 | ||
|
|
0b847ede3e | ||
|
|
08b3585999 | ||
|
|
463dc59d13 | ||
|
|
f9106e5ccd | ||
|
|
0960ef4f0f | ||
|
|
eb8e3616d2 | ||
|
|
6317b5c49f | ||
|
|
39a289ffa7 | ||
|
|
630a3bd5d2 | ||
|
|
f05875d390 | ||
|
|
00ec45088f | ||
|
|
7b0068fc27 | ||
|
|
ea6c67b231 | ||
|
|
b86cc38f97 | ||
|
|
d2b8b14ea8 | ||
|
|
17135522af | ||
|
|
c04ca2ff36 | ||
|
|
e85339e06e | ||
|
|
5fe4ceb651 | ||
|
|
d659610049 | ||
|
|
db012bb659 | ||
|
|
00bff6a281 | ||
|
|
d1403139a9 | ||
|
|
341a4d0935 | ||
|
|
c346306cf8 | ||
|
|
8c3a046bba | ||
|
|
d8a376edb3 | ||
|
|
d77d9a51cf | ||
|
|
ae77dd29da | ||
|
|
ff260f9be3 | ||
|
|
36c0d8205a | ||
|
|
98ade77312 | ||
|
|
93fd770780 | ||
|
|
642760f95d | ||
|
|
a6e14ee3ac | ||
|
|
ee60f5dd29 | ||
|
|
a2bf4c397b | ||
|
|
3780a9fe84 | ||
|
|
42f7088d9d | ||
|
|
c9bc4018a1 | ||
|
|
df62ed7298 | ||
|
|
d5278f836f | ||
|
|
ce8ce7626e | ||
|
|
5dec7c76d4 | ||
|
|
6864ba33cd | ||
|
|
c48f932985 | ||
|
|
ce65f9ae4c | ||
|
|
8765886d6c | ||
|
|
f3c0aee073 | ||
|
|
0eec43d086 | ||
|
|
65af1ca9ec | ||
|
|
66ed8776a1 | ||
|
|
9301920fec | ||
|
|
3030ff3e4e | ||
|
|
1eccdf60ed | ||
|
|
9e4d667161 | ||
|
|
1c5ec977ed | ||
|
|
1b2c11377f | ||
|
|
39093c0749 | ||
|
|
2b38ddd042 | ||
|
|
a515fe0917 | ||
|
|
006eb63bf2 | ||
|
|
19235d024e | ||
|
|
c244b9d685 | ||
|
|
027f90db27 | ||
|
|
b83fb96827 | ||
|
|
380b1de3ff | ||
|
|
3c4a462ae4 | ||
|
|
dfb8b2e376 | ||
|
|
d8f30076b5 | ||
|
|
d9d510e5ac | ||
|
|
ad20475144 | ||
|
|
f623e469a7 | ||
|
|
d5000f5218 | ||
|
|
b3f1fd46eb | ||
|
|
17d73d0c55 | ||
|
|
41cb3a8502 | ||
|
|
4a471894a9 | ||
|
|
998ef75459 | ||
|
|
324b69bb3c | ||
|
|
e2add239a8 | ||
|
|
8dc32a121b | ||
|
|
87097815c0 | ||
|
|
2a7025e758 | ||
|
|
66a3c9620a | ||
|
|
cdcafa0827 | ||
|
|
efb8db8db3 | ||
|
|
74bfab20a6 | ||
|
|
259fe7116e | ||
|
|
c31b852767 | ||
|
|
e8954d609e | ||
|
|
abb8f5c239 | ||
|
|
5a7345c32d | ||
|
|
54f4073f22 | ||
|
|
f3a08a78eb | ||
|
|
2f7267962b | ||
|
|
96ba0cb1f8 | ||
|
|
cb6d38d9f9 | ||
|
|
cd02aeb53c | ||
|
|
e7e502d1e7 | ||
|
|
debda8ac5c | ||
|
|
65d89e187f | ||
|
|
6476d0018c | ||
|
|
d942e963bb | ||
|
|
7181812aff | ||
|
|
62c7b5c989 | ||
|
|
0081fb4e2b | ||
|
|
ebe06c7eee | ||
|
|
87cb98087f | ||
|
|
6e5cd92eec | ||
|
|
0da1c88de7 | ||
|
|
c4615c3f2f | ||
|
|
c6b97a4936 | ||
|
|
26763c33a1 | ||
|
|
a9433195a1 | ||
|
|
1503de3aa5 | ||
|
|
e377dd0d91 | ||
|
|
21e2749356 | ||
|
|
731af391af | ||
|
|
368e52ea6b | ||
|
|
5c299c17c1 | ||
|
|
b20877fac2 | ||
|
|
ab9d0330ff | ||
|
|
ea4f31920b | ||
|
|
8d0603ec6f | ||
|
|
3e00eb4b1e | ||
|
|
b19ed8869c | ||
|
|
e3b8e26192 | ||
|
|
4fcca7a66d | ||
|
|
6965728f11 | ||
|
|
5088a4bb47 | ||
|
|
5a55909579 | ||
|
|
4ef15cae6a | ||
|
|
803c3cd2df | ||
|
|
62cb8a9c74 | ||
|
|
1dfdc7c4f9 | ||
|
|
a5e1e94c0c | ||
|
|
f1e0e463c1 | ||
|
|
3713581520 | ||
|
|
cde9aa5237 | ||
|
|
803d5d3fe5 | ||
|
|
6ab0c21172 | ||
|
|
4d8bd40058 | ||
|
|
5712cf91dc | ||
|
|
ec9eb308ef | ||
|
|
55dcaea347 | ||
|
|
d4c265551a | ||
|
|
f1ab5df40d | ||
|
|
9b2368ca6b | ||
|
|
fdc7338431 | ||
|
|
2ee3927a10 | ||
|
|
7440e27381 | ||
|
|
7ed77c05d0 | ||
|
|
fab9d78c48 | ||
|
|
24b977da27 | ||
|
|
f5ac519089 | ||
|
|
90692c2d92 | ||
|
|
94aa8adebf | ||
|
|
1b4461f1c1 | ||
|
|
0746dcc354 | ||
|
|
ea6935060b | ||
|
|
cc2aa8a5d9 | ||
|
|
cf121dcc10 | ||
|
|
d6ca57ca14 | ||
|
|
0fc3791bb6 | ||
|
|
66f63a074b | ||
|
|
5f52b94aa0 | ||
|
|
a4a57ec662 | ||
|
|
3087b45017 | ||
|
|
618f2b8e00 | ||
|
|
7eb37099d9 | ||
|
|
e07dd9e2f2 | ||
|
|
71d0462287 | ||
|
|
83b53d7b5b | ||
|
|
e7ca2735bf | ||
|
|
05acab169b | ||
|
|
7b5bc3dc50 | ||
|
|
39b7b3ca51 | ||
|
|
d825dd75dc | ||
|
|
f033532d7b | ||
|
|
cade438670 | ||
|
|
14e26b3768 | ||
|
|
cc06433dd3 | ||
|
|
3785891102 | ||
|
|
f1d8d00ab7 | ||
|
|
889bd18da4 | ||
|
|
8dcd7073de | ||
|
|
83805282fd | ||
|
|
d69ba7a8ee | ||
|
|
685aaa7ef4 | ||
|
|
47d3663989 | ||
|
|
eac3d01f78 | ||
|
|
17c1aaf944 | ||
|
|
f457b40293 | ||
|
|
8fc21b7df1 | ||
|
|
4f7e74cb30 | ||
|
|
8e26acd35b | ||
|
|
a9861421de | ||
|
|
8220f05b34 | ||
|
|
fe05ace87b | ||
|
|
c3f7c7c0c4 | ||
|
|
1bf2d191fe | ||
|
|
00861ac5eb | ||
|
|
02f41e145b | ||
|
|
6f35232013 | ||
|
|
99ecbff011 | ||
|
|
3d879197f3 | ||
|
|
6c28ef44ce | ||
|
|
0facec971f | ||
|
|
dbe9309ab8 | ||
|
|
3872eeb7f6 | ||
|
|
7910c10420 | ||
|
|
c369cf211a | ||
|
|
0bf3f5cc8d | ||
|
|
3cc847dc36 | ||
|
|
4df28a09cf | ||
|
|
141c721a4a | ||
|
|
dcb4b4f6af | ||
|
|
aa8f1fcd3a | ||
|
|
33ef9a75f2 | ||
|
|
effbe2ea32 | ||
|
|
83883ad970 | ||
|
|
5bd01c2060 | ||
|
|
3fb85f76ad | ||
|
|
09698d3cca | ||
|
|
a222552d92 | ||
|
|
33049c22f8 | ||
|
|
923d92f9f5 | ||
|
|
1359e57e70 | ||
|
|
f7ae7b6d01 | ||
|
|
744e5bb39b | ||
|
|
7872c168b4 | ||
|
|
b69df4d8b8 | ||
|
|
0aa20ae2d7 | ||
|
|
8bff4ad6a6 | ||
|
|
270568f852 | ||
|
|
e91a8edc36 | ||
|
|
8102836ca8 | ||
|
|
685f611f86 | ||
|
|
01552e03d6 | ||
|
|
3094a00211 | ||
|
|
e1bacf098a | ||
|
|
37510f544f | ||
|
|
33e5b84074 | ||
|
|
953b264c07 | ||
|
|
c440fa1beb | ||
|
|
330288ba3d | ||
|
|
d95049cec8 | ||
|
|
615628e4f0 | ||
|
|
7d2a377374 | ||
|
|
080a09b0f5 | ||
|
|
f3df36aec1 | ||
|
|
a87e396b02 | ||
|
|
6e4893f37f | ||
|
|
c7ae155a07 | ||
|
|
a5d8af27d0 | ||
|
|
179968303a | ||
|
|
202f650b7c | ||
|
|
f23739272f | ||
|
|
0958b120e2 | ||
|
|
e6bf7d333c | ||
|
|
98ff51d570 | ||
|
|
b1249dfc79 | ||
|
|
eef27dfc8e | ||
|
|
85dd61de3a | ||
|
|
fee28ca253 | ||
|
|
1cbea4a95f | ||
|
|
b1f11384b5 | ||
|
|
890c062c01 | ||
|
|
ed4dd78210 | ||
|
|
8bafbf7238 | ||
|
|
f6522e6a34 | ||
|
|
a5336da117 | ||
|
|
a0d2d0302e | ||
|
|
f5ea9bbc02 | ||
|
|
3d7c403580 | ||
|
|
c3554ad525 | ||
|
|
f805038d16 | ||
|
|
53542cf84e | ||
|
|
6609ac0b1e | ||
|
|
7928826b0f | ||
|
|
2a38ef22e5 | ||
|
|
011863ee33 | ||
|
|
ab3bb9dd6c | ||
|
|
b5082d2796 | ||
|
|
b388ae71d7 | ||
|
|
dfa144da3f | ||
|
|
e12eaf055c | ||
|
|
cda6b22a0c | ||
|
|
3a2a400dcb | ||
|
|
bae833a355 | ||
|
|
8fa6f74a17 | ||
|
|
e7019ad7e0 | ||
|
|
610d371112 | ||
|
|
d6ebd4ec2b | ||
|
|
57e62bb226 | ||
|
|
1b2c8b5737 | ||
|
|
f72c498a8a | ||
|
|
3c5204c8c7 | ||
|
|
a8ffc6072d | ||
|
|
3161f70ac3 | ||
|
|
36fcc3499d | ||
|
|
1f40540ef0 | ||
|
|
c52f4eb61e | ||
|
|
bafb0a689f | ||
|
|
8e670491a3 | ||
|
|
ecf72a0860 | ||
|
|
742d1b93b0 | ||
|
|
9f7528b10f | ||
|
|
505d3d9f03 | ||
|
|
a214565031 | ||
|
|
fc8e796620 | ||
|
|
433a553f88 | ||
|
|
3ed6d9f9af | ||
|
|
f0917cc325 | ||
|
|
56bac46fd6 | ||
|
|
cb7be807fd | ||
|
|
dc4c82c006 | ||
|
|
a866e2e2f3 | ||
|
|
963410e562 | ||
|
|
5e0d54d4ce | ||
|
|
94cf4c2957 | ||
|
|
aa43aee224 | ||
|
|
9d1a3c5a00 | ||
|
|
ebe0a269ea | ||
|
|
edff7a4471 | ||
|
|
9009960b35 | ||
|
|
9969c9f845 | ||
|
|
2dace438a3 | ||
|
|
19a8683bca | ||
|
|
154926d4ac | ||
|
|
e740e6c423 | ||
|
|
703289fb0b | ||
|
|
89219c479c | ||
|
|
3bbbdd478f | ||
|
|
8bf624401a | ||
|
|
9d5f87f0b4 | ||
|
|
f8ca584a81 | ||
|
|
520999691f | ||
|
|
4b1f4f3223 | ||
|
|
5cc0f16bae | ||
|
|
e9caaa43a3 | ||
|
|
793300066b | ||
|
|
8b456fb4df | ||
|
|
6c052bb9b5 | ||
|
|
bc3ef4de71 | ||
|
|
a14b9be0be | ||
|
|
f39e55caf6 | ||
|
|
d5964397b2 | ||
|
|
0065d0e0f1 | ||
|
|
5053828fad | ||
|
|
fd38ac854c | ||
|
|
3b033f9eb5 | ||
|
|
0451a321cb | ||
|
|
cfe627e894 | ||
|
|
47788f1617 | ||
|
|
dcdf6715a3 | ||
|
|
ea94659e7c | ||
|
|
b3e0f6c5b7 | ||
|
|
4bdc28959d | ||
|
|
3482166f78 | ||
|
|
ff1c935761 | ||
|
|
1a9c03c0af | ||
|
|
a43f2ccc0a | ||
|
|
4e00f708f7 | ||
|
|
c9a0aff2f9 | ||
|
|
5afbf25a59 | ||
|
|
b29abeafb5 | ||
|
|
3a8676371c | ||
|
|
5a13489fdd | ||
|
|
2f5fee324a | ||
|
|
c9eb56c4cb | ||
|
|
6aa5804503 | ||
|
|
bbba892849 | ||
|
|
62d95441d1 | ||
|
|
09479cafa4 | ||
|
|
26a5465c5b | ||
|
|
64558c418c | ||
|
|
785622f7f3 | ||
|
|
43440c6edb | ||
|
|
7a6c9934d7 | ||
|
|
21a4e6fbcc | ||
|
|
a4621041b6 | ||
|
|
7c7d30fe1f | ||
|
|
4ce6b1f620 | ||
|
|
f0fb69f4c7 | ||
|
|
3ac6e1b115 | ||
|
|
8767767794 | ||
|
|
a7ca830fad | ||
|
|
acf56bf154 | ||
|
|
63615b2fab | ||
|
|
7196a370a7 | ||
|
|
f1f9e34683 | ||
|
|
fa7a349a2a | ||
|
|
ac902eb2e9 | ||
|
|
bbef12212f | ||
|
|
b252c0834d | ||
|
|
8b908cbc43 | ||
|
|
0aea96233d | ||
|
|
977b2c4c93 | ||
|
|
b441e3e838 | ||
|
|
b91a3b8c27 | ||
|
|
49695a7575 | ||
|
|
654904fce0 | ||
|
|
2f1276864d | ||
|
|
5ff641a49c | ||
|
|
75bb174719 | ||
|
|
fe6375e39c | ||
|
|
9939df18f5 | ||
|
|
cac9be9d98 | ||
|
|
e3350f7ada | ||
|
|
7526ab3163 | ||
|
|
893fe3bb01 | ||
|
|
851b01578e | ||
|
|
765f1172d5 | ||
|
|
6137c60651 | ||
|
|
975b1a6303 | ||
|
|
045b39c6d6 | ||
|
|
385f2196a9 | ||
|
|
9f73e16373 | ||
|
|
cd48673c20 | ||
|
|
33a8d28d0d | ||
|
|
38a6567bad | ||
|
|
1434758d42 | ||
|
|
2941960545 | ||
|
|
c50ec44bd8 | ||
|
|
3a6b69bfe7 | ||
|
|
78d43c6712 | ||
|
|
fcf05b4b63 | ||
|
|
58abd13450 | ||
|
|
38b1a406bb | ||
|
|
dd6a28ca1f | ||
|
|
f2b0aaeb8f | ||
|
|
977c6ca61f | ||
|
|
a8879e7360 | ||
|
|
80365aec68 | ||
|
|
b1b01630e5 | ||
|
|
ec2e511d0d | ||
|
|
003c7b4fa9 | ||
|
|
5caa2eea03 | ||
|
|
d326d38a0a | ||
|
|
4275c24dc5 | ||
|
|
542544d8eb | ||
|
|
f835f0c59c | ||
|
|
d6fd31c756 | ||
|
|
0ef7bf929e | ||
|
|
64cb61f902 | ||
|
|
779f57902e | ||
|
|
4bfd7fd17c | ||
|
|
ce775fd3f9 | ||
|
|
a0a8ed7594 | ||
|
|
111260fa37 | ||
|
|
f3853f48df | ||
|
|
7fde7b7ca4 | ||
|
|
68c59ad047 | ||
|
|
7e3514adae | ||
|
|
ffbde43b2c | ||
|
|
64b7451003 | ||
|
|
3ec3242a66 | ||
|
|
238887036b | ||
|
|
4ec9fb0a58 | ||
|
|
51b668a2a6 | ||
|
|
ce38c21253 | ||
|
|
f5ba4a8e51 | ||
|
|
52100bbb29 | ||
|
|
65e6d72254 | ||
|
|
48b5dca16a | ||
|
|
fbe4e586f6 | ||
|
|
ca99dda275 | ||
|
|
b28d45bb2d | ||
|
|
404f79f370 | ||
|
|
5a002d8f6a | ||
|
|
606d1d7926 | ||
|
|
277c1ddf9c | ||
|
|
d6fc092795 | ||
|
|
3a422d4dde | ||
|
|
eb8cda6355 | ||
|
|
c81c774b49 | ||
|
|
b6431e82cd | ||
|
|
9d3b0ddf58 | ||
|
|
a6948b57e6 | ||
|
|
4cc89a054b | ||
|
|
03b18bd4e3 | ||
|
|
2907adb12e | ||
|
|
d08ca0e069 | ||
|
|
ca22915bf1 | ||
|
|
a4430fd373 | ||
|
|
90e802d5e4 | ||
|
|
6113893a53 | ||
|
|
5aea0b1489 | ||
|
|
6d59f60f88 | ||
|
|
a5a08455b2 | ||
|
|
bc296aa440 | ||
|
|
d01d63275e | ||
|
|
00e67c1db1 | ||
|
|
946603c513 | ||
|
|
6414d0a020 | ||
|
|
22cab8f4bd | ||
|
|
f08d5343d0 | ||
|
|
8c0fa26938 | ||
|
|
e07c1be932 | ||
|
|
1e4ecbfe0a | ||
|
|
fd242a37de | ||
|
|
2d8440309d | ||
|
|
4bdc2a3d71 | ||
|
|
668b99fab7 | ||
|
|
14942e7f1c | ||
|
|
c1ace7800b | ||
|
|
9a602ab6d6 | ||
|
|
36eddb6a6b | ||
|
|
340cf3a5e8 | ||
|
|
1e20db0758 | ||
|
|
2c8f39c217 | ||
|
|
a87d0bed3a | ||
|
|
c01167b1f4 | ||
|
|
23b06d5710 | ||
|
|
4731f0fd08 | ||
|
|
cf791ff9f4 | ||
|
|
2f2889f957 | ||
|
|
22ba24b6a4 | ||
|
|
bbfbe3f94c | ||
|
|
a8a081486b | ||
|
|
f966ada97b | ||
|
|
ba865c7c9c | ||
|
|
f72b30f5a2 | ||
|
|
30888bfcf9 | ||
|
|
067e95435f | ||
|
|
c0c0b22415 | ||
|
|
b95dd9594e | ||
|
|
fda3de1ec0 | ||
|
|
1c1fd3517c | ||
|
|
7f162a924c | ||
|
|
d3fcb6e045 | ||
|
|
a3d17b5e86 | ||
|
|
e1595f4b55 | ||
|
|
ae20b3b189 | ||
|
|
cd05b1d3e3 | ||
|
|
e61d4ff85d | ||
|
|
8b72e93067 | ||
|
|
ceea0b0222 | ||
|
|
137354df91 | ||
|
|
039ca228fa | ||
|
|
44f7a814c3 | ||
|
|
d5e702894a | ||
|
|
33f8999c60 | ||
|
|
f5f3c59d33 | ||
|
|
a046f66b76 | ||
|
|
05259faaf9 | ||
|
|
6d815cb16c | ||
|
|
7aa44930a7 | ||
|
|
4763a02d55 | ||
|
|
d82c30da65 | ||
|
|
fff55e65ea | ||
|
|
53fe349fa8 | ||
|
|
a05db83783 | ||
|
|
ac5cde8d1e | ||
|
|
2c40f2d3cd | ||
|
|
a006cd91a2 | ||
|
|
136eef8c70 | ||
|
|
b510cdcc48 | ||
|
|
64745080f7 | ||
|
|
b991a91b80 | ||
|
|
5a042854bd | ||
|
|
61cec2e1be | ||
|
|
3589fb3efc | ||
|
|
59295c05bf | ||
|
|
3fd75f6bd5 | ||
|
|
da94a3e334 | ||
|
|
9c96d3d417 | ||
|
|
f7dc60bf90 | ||
|
|
58f17aa5a8 | ||
|
|
95f76e12ef | ||
|
|
3933334a4d | ||
|
|
59e68de46b | ||
|
|
e1e20c9981 | ||
|
|
1495ea9a8f | ||
|
|
1af973bd79 | ||
|
|
15ac8b8954 | ||
|
|
1cd7699660 | ||
|
|
d264aee057 | ||
|
|
8f24e8af40 | ||
|
|
f92e7339f4 | ||
|
|
7908dd69be | ||
|
|
a5ff29eea5 | ||
|
|
2fe4790722 | ||
|
|
35ddd2b6f4 | ||
|
|
94dfaae3d0 | ||
|
|
78154faa62 | ||
|
|
1ff2a7c05e | ||
|
|
bb22386e30 | ||
|
|
18fa5392fe | ||
|
|
33af2faf83 | ||
|
|
e9e2b6e2a9 | ||
|
|
61c43c95e5 | ||
|
|
9316880ecb | ||
|
|
e39a7b10de | ||
|
|
2bb01984ed | ||
|
|
77e41a37ee | ||
|
|
79229b7ede | ||
|
|
fd20d4f536 | ||
|
|
796f32baa2 | ||
|
|
a605790a84 | ||
|
|
36ae648a63 | ||
|
|
59bf9019c9 | ||
|
|
c409d531da | ||
|
|
5fef0cf07c | ||
|
|
511ca69e4e | ||
|
|
4c7e60d7e7 | ||
|
|
068aced02e | ||
|
|
a9e163403d | ||
|
|
bc136b89f9 | ||
|
|
b1bd4f9a8b | ||
|
|
06d3927f05 | ||
|
|
5c3516cae8 | ||
|
|
1c18d8ddbd | ||
|
|
253dfe9351 | ||
|
|
2d3999e984 | ||
|
|
a431cc726d | ||
|
|
08493d2429 | ||
|
|
8dd0969006 | ||
|
|
2d80d3c35c | ||
|
|
4da764904c | ||
|
|
5c4f4fd1ef | ||
|
|
54843971ac | ||
|
|
5ec5d3aeb9 | ||
|
|
d661b53e49 | ||
|
|
fd1e777f85 | ||
|
|
e5f77ce185 | ||
|
|
60bc4eaf3c | ||
|
|
208073e228 | ||
|
|
fa1904e7ee | ||
|
|
99929d1c16 | ||
|
|
c699aa11db | ||
|
|
06cd0b56ac | ||
|
|
241a9e1fec | ||
|
|
3d79689fb5 | ||
|
|
9f050f7957 | ||
|
|
cb691d47d3 | ||
|
|
04f5ee58d0 | ||
|
|
f5cff724f4 | ||
|
|
1f4ccafdea | ||
|
|
163049f6ca | ||
|
|
895af9c1d1 | ||
|
|
2bec40d7be | ||
|
|
9fe9aa5157 | ||
|
|
6d18502041 | ||
|
|
e1432889cb | ||
|
|
0d65a338ef | ||
|
|
3f2fa4d7de | ||
|
|
0a23c0c386 | ||
|
|
49bd13248f | ||
|
|
65e7507ca3 | ||
|
|
ba157f9604 | ||
|
|
c989c3c6ac | ||
|
|
0dc913630e | ||
|
|
875cd30b1f | ||
|
|
aa4a32ae84 | ||
|
|
8573e14777 | ||
|
|
1855be234c | ||
|
|
9bb960ff12 | ||
|
|
1c14c7d467 | ||
|
|
61faec16ca | ||
|
|
57c836e692 | ||
|
|
7d4718d8c5 | ||
|
|
3b9176f538 | ||
|
|
626cc282b1 | ||
|
|
52624d54e3 | ||
|
|
5ce038ef92 | ||
|
|
30f4d4c0c6 | ||
|
|
510679a7d1 | ||
|
|
33a61e32ed | ||
|
|
2a7968d210 | ||
|
|
9237f2a14a | ||
|
|
bd178c7b29 | ||
|
|
d80947aa72 | ||
|
|
037b7f6b5f | ||
|
|
1618c37d26 | ||
|
|
701ab64462 | ||
|
|
3e6c3ace7b | ||
|
|
f26812310f | ||
|
|
b3298800e9 | ||
|
|
0f1c6b3f78 | ||
|
|
9b4b246aad | ||
|
|
1bd654a289 | ||
|
|
fc70329bdb | ||
|
|
cd8a82fbd4 | ||
|
|
550cb7eff5 | ||
|
|
2e748c16e9 | ||
|
|
577012b66d | ||
|
|
251c324180 | ||
|
|
865a618fd5 | ||
|
|
991116990a | ||
|
|
eb5d1da0e8 | ||
|
|
0a9f1d58bd | ||
|
|
a1a2abb533 | ||
|
|
dcc660daa2 | ||
|
|
3f3422c37f | ||
|
|
ccbb556a8b | ||
|
|
ad2094c96f | ||
|
|
dce3b7a7fc | ||
|
|
be386aba3b | ||
|
|
02e51e7d7c | ||
|
|
96b6d4f2c0 | ||
|
|
4c84c5957d | ||
|
|
34613ecdc5 | ||
|
|
6a14245401 | ||
|
|
a758ca2aef | ||
|
|
9e60d4e213 | ||
|
|
ef29c8442c | ||
|
|
a1b45415ac | ||
|
|
7fc46d75bd | ||
|
|
953a4ef0ca | ||
|
|
0f77b623d8 | ||
|
|
87e7e1ca2d | ||
|
|
796328e9f2 | ||
|
|
526059faeb | ||
|
|
272bdeb555 |
@@ -56,6 +56,7 @@ from . import (
|
||||
knowledge_fs_proxy,
|
||||
notification,
|
||||
onboarding,
|
||||
resource_access_token,
|
||||
setup,
|
||||
spec,
|
||||
system,
|
||||
@@ -234,6 +235,7 @@ __all__ = [
|
||||
"rag_pipeline_workflow",
|
||||
"rbac",
|
||||
"recommended_app",
|
||||
"resource_access_token",
|
||||
"saved_message",
|
||||
"setup",
|
||||
"site",
|
||||
|
||||
256
api/controllers/console/resource_access_token.py
Normal file
256
api/controllers/console/resource_access_token.py
Normal file
@@ -0,0 +1,256 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import Field, field_validator
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
model_validate,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import dump_response, to_timestamp
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from models.account import TenantAccountRole
|
||||
from models.resource_access_token import ResourceAccessTokenResourceType
|
||||
from services.resource_access_token_service import (
|
||||
ResourceAccessTokenResource,
|
||||
ResourceAccessTokenRow,
|
||||
ResourceAccessTokenService,
|
||||
)
|
||||
|
||||
|
||||
class ResourceAccessTokenResourcePayload(ResponseModel):
|
||||
type: ResourceAccessTokenResourceType
|
||||
id: str
|
||||
|
||||
|
||||
class ResourceAccessTokenCreatePayload(ResponseModel):
|
||||
name: str = Field(min_length=1)
|
||||
resources: list[ResourceAccessTokenResourcePayload] = Field(min_length=1)
|
||||
|
||||
|
||||
class ResourceAccessTokenUpdatePayload(ResponseModel):
|
||||
name: str = Field(min_length=1)
|
||||
resources: list[ResourceAccessTokenResourcePayload] = Field(min_length=1)
|
||||
|
||||
|
||||
class ResourceAccessTokenListQuery(ResponseModel):
|
||||
page: int = Field(default=1, ge=1)
|
||||
limit: int = Field(default=20, ge=1, le=100)
|
||||
|
||||
|
||||
class ResourceAccessTokenRowResponse(ResponseModel):
|
||||
token_id: str
|
||||
relation_id: str
|
||||
name: str
|
||||
track_id: str
|
||||
token: str | None = None
|
||||
masked_token: str
|
||||
resource_type: ResourceAccessTokenResourceType
|
||||
resource_id: str
|
||||
resource_name: str
|
||||
created_at: int
|
||||
last_used_at: int | None = None
|
||||
|
||||
@field_validator("created_at", "last_used_at", mode="before")
|
||||
@classmethod
|
||||
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
|
||||
return to_timestamp(value)
|
||||
|
||||
|
||||
class ResourceAccessTokenListResponse(ResponseModel):
|
||||
data: list[ResourceAccessTokenRowResponse]
|
||||
page: int
|
||||
limit: int
|
||||
total: int
|
||||
has_more: bool
|
||||
|
||||
|
||||
class ResourceAccessTokenCreateResponse(ResponseModel):
|
||||
token: str
|
||||
data: list[ResourceAccessTokenRowResponse]
|
||||
|
||||
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
ResourceAccessTokenResourcePayload,
|
||||
ResourceAccessTokenCreatePayload,
|
||||
ResourceAccessTokenUpdatePayload,
|
||||
ResourceAccessTokenListQuery,
|
||||
)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
ResourceAccessTokenRowResponse,
|
||||
ResourceAccessTokenListResponse,
|
||||
ResourceAccessTokenCreateResponse,
|
||||
)
|
||||
|
||||
|
||||
def _require_owner(current_user: Account) -> None:
|
||||
if current_user.current_role != TenantAccountRole.OWNER:
|
||||
raise Forbidden()
|
||||
|
||||
|
||||
def _dump_rows(rows: list[ResourceAccessTokenRow]) -> list[dict[str, object]]:
|
||||
return [
|
||||
dump_response(
|
||||
ResourceAccessTokenRowResponse,
|
||||
ResourceAccessTokenRowResponse.model_validate(row, from_attributes=True),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@console_ns.route("/resource-access-tokens")
|
||||
class ResourceAccessTokenListApi(Resource):
|
||||
method_decorators = [account_initialization_required, login_required, setup_required]
|
||||
|
||||
@console_ns.doc("list_resource_access_tokens")
|
||||
@console_ns.doc(params=query_params_from_model(ResourceAccessTokenListQuery))
|
||||
@console_ns.response(200, "Resource access tokens", console_ns.models[ResourceAccessTokenListResponse.__name__])
|
||||
@model_validate(ResourceAccessTokenListQuery)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(
|
||||
self,
|
||||
session: Session,
|
||||
current_tenant_id: str,
|
||||
current_user: Account,
|
||||
query: ResourceAccessTokenListQuery,
|
||||
) -> dict[str, object]:
|
||||
_require_owner(current_user)
|
||||
rows = ResourceAccessTokenService.list_rows(
|
||||
tenant_id=current_tenant_id,
|
||||
page=query.page,
|
||||
limit=query.limit,
|
||||
session=session,
|
||||
)
|
||||
total = ResourceAccessTokenService.count_rows(tenant_id=current_tenant_id, session=session)
|
||||
return dump_response(
|
||||
ResourceAccessTokenListResponse,
|
||||
{
|
||||
"data": _dump_rows(rows),
|
||||
"page": query.page,
|
||||
"limit": query.limit,
|
||||
"total": total,
|
||||
"has_more": query.page * query.limit < total,
|
||||
},
|
||||
)
|
||||
|
||||
@console_ns.doc("create_resource_access_token")
|
||||
@console_ns.expect(console_ns.models[ResourceAccessTokenCreatePayload.__name__])
|
||||
@console_ns.response(
|
||||
201,
|
||||
"Resource access token created",
|
||||
console_ns.models[ResourceAccessTokenCreateResponse.__name__],
|
||||
)
|
||||
@model_validate(ResourceAccessTokenCreatePayload)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def post(
|
||||
self,
|
||||
session: Session,
|
||||
current_tenant_id: str,
|
||||
current_user: Account,
|
||||
payload: ResourceAccessTokenCreatePayload,
|
||||
) -> tuple[dict[str, object], int]:
|
||||
_require_owner(current_user)
|
||||
result = ResourceAccessTokenService.create(
|
||||
tenant_id=current_tenant_id,
|
||||
created_by=current_user.id,
|
||||
name=payload.name,
|
||||
resources=[
|
||||
ResourceAccessTokenResource(type=resource.type, id=resource.id) for resource in payload.resources
|
||||
],
|
||||
session=session,
|
||||
)
|
||||
return dump_response(
|
||||
ResourceAccessTokenCreateResponse,
|
||||
{"token": result.token, "data": _dump_rows(result.rows)},
|
||||
), 201
|
||||
|
||||
|
||||
@console_ns.route("/resource-access-tokens/<uuid:token_id>")
|
||||
class ResourceAccessTokenApi(Resource):
|
||||
method_decorators = [account_initialization_required, login_required, setup_required]
|
||||
|
||||
@console_ns.doc("update_resource_access_token")
|
||||
@console_ns.expect(console_ns.models[ResourceAccessTokenUpdatePayload.__name__])
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Resource access token updated",
|
||||
console_ns.models[ResourceAccessTokenListResponse.__name__],
|
||||
)
|
||||
@model_validate(ResourceAccessTokenUpdatePayload)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def patch(
|
||||
self,
|
||||
session: Session,
|
||||
current_tenant_id: str,
|
||||
current_user: Account,
|
||||
payload: ResourceAccessTokenUpdatePayload,
|
||||
token_id: UUID,
|
||||
) -> dict[str, object]:
|
||||
_require_owner(current_user)
|
||||
rows = ResourceAccessTokenService.update(
|
||||
tenant_id=current_tenant_id,
|
||||
token_id=str(token_id),
|
||||
name=payload.name,
|
||||
resources=[
|
||||
ResourceAccessTokenResource(type=resource.type, id=resource.id) for resource in payload.resources
|
||||
],
|
||||
session=session,
|
||||
)
|
||||
return dump_response(
|
||||
ResourceAccessTokenListResponse,
|
||||
{
|
||||
"data": _dump_rows(rows),
|
||||
"page": 1,
|
||||
"limit": len(rows),
|
||||
"total": len(rows),
|
||||
"has_more": False,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/resource-access-tokens/<uuid:token_id>/relations/<uuid:relation_id>")
|
||||
class ResourceAccessTokenRelationApi(Resource):
|
||||
method_decorators = [account_initialization_required, login_required, setup_required]
|
||||
|
||||
@console_ns.doc("delete_resource_access_token_relation")
|
||||
@console_ns.response(204, "Resource access token relation deleted")
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def delete(
|
||||
self,
|
||||
session: Session,
|
||||
current_tenant_id: str,
|
||||
current_user: Account,
|
||||
token_id: UUID,
|
||||
relation_id: UUID,
|
||||
) -> tuple[str, int]:
|
||||
_require_owner(current_user)
|
||||
ResourceAccessTokenService.delete_relation(
|
||||
tenant_id=current_tenant_id,
|
||||
token_id=str(token_id),
|
||||
relation_id=str(relation_id),
|
||||
session=session,
|
||||
)
|
||||
return "", 204
|
||||
@@ -34,7 +34,7 @@ from controllers.openapi.auth.requirements import (
|
||||
CheckSubject,
|
||||
CheckWorkspaceMember,
|
||||
)
|
||||
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject
|
||||
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject, ResourceAccessSubject
|
||||
from controllers.service_api.app.error import (
|
||||
AppUnavailableError,
|
||||
CompletionRequestError,
|
||||
@@ -155,7 +155,7 @@ _DISPATCH: dict[AppMode, Callable[[App, Any, AppRunRequest, Session], Any]] = {
|
||||
class AppRunApi(Resource):
|
||||
@endpoint(
|
||||
requirements=(
|
||||
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject)),
|
||||
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject, ResourceAccessSubject)),
|
||||
CheckAppApiEnabled(),
|
||||
CheckWorkspaceMember(),
|
||||
CheckScope(Scope.APPS_RUN),
|
||||
@@ -197,7 +197,7 @@ class AppRunApi(Resource):
|
||||
class AppRunTaskStopApi(Resource):
|
||||
@endpoint(
|
||||
requirements=(
|
||||
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject)),
|
||||
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject, ResourceAccessSubject)),
|
||||
CheckAppApiEnabled(),
|
||||
CheckWorkspaceMember(),
|
||||
CheckScope(Scope.APPS_RUN),
|
||||
@@ -207,6 +207,11 @@ class AppRunTaskStopApi(Resource):
|
||||
returns=(200, TaskStopResponse, "Task stopped"),
|
||||
)
|
||||
def post(self, ctx: Context, app_id: str, task_id: str):
|
||||
if isinstance(ctx.subject, ResourceAccessSubject):
|
||||
# A bound app must not let a machine token stop another caller's task.
|
||||
owner = redis_client.get(AppQueueManager._generate_task_belong_cache_key(task_id))
|
||||
if owner != f"end-user-{ctx.end_user.id}".encode():
|
||||
raise NotFound("Task not found")
|
||||
AppQueueManager.set_stop_flag_no_user_check(task_id)
|
||||
GraphEngineManager(redis_client).send_stop_command(task_id)
|
||||
return TaskStopResponse(result="success")
|
||||
|
||||
@@ -32,7 +32,7 @@ from controllers.openapi.auth.requirements import (
|
||||
CheckSubject,
|
||||
CheckWorkspaceMember,
|
||||
)
|
||||
from controllers.openapi.auth.subjects import AccountSubject
|
||||
from controllers.openapi.auth.subjects import AccountSubject, ResourceAccessSubject
|
||||
from controllers.service_api.app.error import AppUnavailableError
|
||||
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
|
||||
from libs.oauth_bearer import Scope
|
||||
@@ -104,7 +104,7 @@ def build_app_describe_response(app: App, fields: set[str] | None, *, session: S
|
||||
class AppDescribeApi(Resource):
|
||||
@endpoint(
|
||||
requirements=(
|
||||
CheckSubject(allowed=(AccountSubject,)),
|
||||
CheckSubject(allowed=(AccountSubject, ResourceAccessSubject)),
|
||||
CheckAppApiEnabled(),
|
||||
CheckWorkspaceMember(),
|
||||
CheckScope(Scope.APPS_READ),
|
||||
@@ -122,7 +122,7 @@ class AppDescribeApi(Resource):
|
||||
class AppListApi(Resource):
|
||||
@endpoint(
|
||||
requirements=(
|
||||
CheckSubject(allowed=(AccountSubject,)),
|
||||
CheckSubject(allowed=(AccountSubject, ResourceAccessSubject)),
|
||||
CheckScope(Scope.APPS_READ),
|
||||
CheckWorkspaceMember(),
|
||||
),
|
||||
@@ -131,7 +131,7 @@ class AppListApi(Resource):
|
||||
)
|
||||
def get(self, ctx: Context, *, query: AppListQuery):
|
||||
workspace_id = query.workspace_id
|
||||
account_id = str(ctx.subject.account_id)
|
||||
account_id = str(ctx.subject.account_id or ctx.subject.token_id)
|
||||
|
||||
empty = AppListResponse.build(page=query.page, limit=query.limit, total=0, items=[])
|
||||
|
||||
@@ -145,7 +145,7 @@ class AppListApi(Resource):
|
||||
|
||||
access_filter = (
|
||||
resolve_app_access_filter(workspace_id, account_id, session=ctx.session)
|
||||
if dify_config.RBAC_ENABLED
|
||||
if dify_config.RBAC_ENABLED and isinstance(ctx.subject, AccountSubject)
|
||||
else AppAccessFilter.unrestricted()
|
||||
)
|
||||
|
||||
@@ -154,6 +154,10 @@ class AppListApi(Resource):
|
||||
app: App | None = AppService.get_visible_app_by_id(str(parsed_uuid), ctx.session)
|
||||
if app is None or str(app.tenant_id) != workspace_id:
|
||||
return empty
|
||||
if ctx.resource_app_ids is not None and (
|
||||
str(app.id) not in ctx.resource_app_ids or app.status != AppStatus.NORMAL
|
||||
):
|
||||
return empty
|
||||
if not _is_listable(app):
|
||||
return empty
|
||||
# Apply RBAC visibility to the UUID fast-path the same way the service
|
||||
@@ -188,6 +192,9 @@ class AppListApi(Resource):
|
||||
|
||||
access_filter.apply_to_params(params)
|
||||
|
||||
if ctx.resource_app_ids is not None:
|
||||
params.accessible_app_ids = list(ctx.resource_app_ids)
|
||||
params.include_own_apps = False
|
||||
pagination = AppService().get_paginate_apps(account_id, workspace_id, params, ctx.session)
|
||||
if pagination is None:
|
||||
return empty
|
||||
|
||||
@@ -39,6 +39,7 @@ class Context:
|
||||
subject: Subject
|
||||
session: Session
|
||||
view_args: Mapping[str, str]
|
||||
resource_app_ids: frozenset[str] | None = field(default=None, init=False)
|
||||
_app: App | None = field(default=None, init=False)
|
||||
_workspace: Tenant | None = field(default=None, init=False)
|
||||
_workspace_role: TenantAccountRole | None = field(default=None, init=False)
|
||||
|
||||
@@ -17,8 +17,9 @@ from controllers.openapi.auth.requirements import (
|
||||
Requirement,
|
||||
ResolveCaller,
|
||||
)
|
||||
from controllers.openapi.auth.resource_access import CheckResourceAccess
|
||||
from controllers.openapi.auth.spec import EndpointSpec
|
||||
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject, Subject
|
||||
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject, ResourceAccessSubject, Subject
|
||||
from enums import DeploymentEdition
|
||||
from libs.oauth_bearer import AuthContext, reset_auth_ctx, set_auth_ctx
|
||||
from models.account import Account
|
||||
@@ -63,6 +64,10 @@ class AccountPipeline(Pipeline, serves=AccountSubject):
|
||||
fixed = (ResolveCaller(),)
|
||||
|
||||
|
||||
class ResourceAccessPipeline(Pipeline, serves=ResourceAccessSubject):
|
||||
fixed = (CheckResourceAccess(), ResolveCaller())
|
||||
|
||||
|
||||
class _RequiresEnterprise(Requirement):
|
||||
"""A gate on the token kind, not on a route, so no endpoint declares it.
|
||||
It runs after `authenticate` on purpose: a `dfoe_` string no row backs
|
||||
|
||||
@@ -22,7 +22,7 @@ from controllers.common.rbac import RBACCheck, enforce_rbac_checks
|
||||
from controllers.openapi._audit import emit_wrong_surface
|
||||
from controllers.openapi.auth.context import Context
|
||||
from controllers.openapi.auth.loaders import load_app, load_caller, load_workspace, load_workspace_role
|
||||
from controllers.openapi.auth.subjects import Subject
|
||||
from controllers.openapi.auth.subjects import ResourceAccessSubject, Subject
|
||||
from enums import DeploymentEdition
|
||||
from libs.oauth_bearer import Scope
|
||||
from models.account import TenantAccountRole
|
||||
@@ -168,6 +168,9 @@ class CheckAppAccess(Requirement):
|
||||
|
||||
@override
|
||||
def run(self, subject: Subject, ctx: Context, session: Session) -> None:
|
||||
# Machine access is authorized by live resource bindings in its pipeline.
|
||||
if isinstance(subject, ResourceAccessSubject):
|
||||
return
|
||||
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE:
|
||||
return
|
||||
access_mode = self._access_mode(str(load_app(ctx).id))
|
||||
|
||||
91
api/controllers/openapi/auth/resource_access.py
Normal file
91
api/controllers/openapi/auth/resource_access.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Resource-token authentication and live resource authorization for OpenAPI.
|
||||
|
||||
Machine credentials never inherit their creator's account or workspace role.
|
||||
Re-read bindings on every request so revocation takes effect without cache lag.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import override
|
||||
|
||||
from flask import request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden, Unauthorized
|
||||
|
||||
from controllers.openapi.auth.context import Context
|
||||
from controllers.openapi.auth.requirements import Rank, Requirement
|
||||
from controllers.openapi.auth.subjects import Subject
|
||||
from core.db.session_factory import session_factory
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from libs.oauth_bearer import AuthContext, TokenType, sha256_hex
|
||||
from libs.rate_limit import enforce_bearer_rate_limit
|
||||
from models.account import Tenant, TenantStatus
|
||||
from models.enums import AppStatus
|
||||
from models.model import App
|
||||
from models.resource_access_token import (
|
||||
ResourceAccessToken,
|
||||
ResourceAccessTokenRelation,
|
||||
ResourceAccessTokenResourceType,
|
||||
)
|
||||
|
||||
|
||||
def authenticate_resource_token(token: str) -> AuthContext:
|
||||
token_hash = sha256_hex(token)
|
||||
enforce_bearer_rate_limit(token_hash)
|
||||
with session_factory.create_session() as session:
|
||||
row = session.scalar(select(ResourceAccessToken).where(ResourceAccessToken.token == token))
|
||||
if row is None:
|
||||
raise Unauthorized("invalid_bearer")
|
||||
token_id = uuid.UUID(str(row.id))
|
||||
return AuthContext(
|
||||
subject_email=None,
|
||||
subject_issuer=None,
|
||||
account_id=None,
|
||||
client_id=None,
|
||||
token_id=token_id,
|
||||
token_type=TokenType.RESOURCE_ACCESS,
|
||||
expires_at=None,
|
||||
)
|
||||
|
||||
|
||||
class CheckResourceAccess(Requirement):
|
||||
# CheckSubject rejects unsupported routes first, then bindings are loaded
|
||||
# before any app or workspace requirements can read their context.
|
||||
rank = Rank.FIRST
|
||||
|
||||
@override
|
||||
def run(self, subject: Subject, ctx: Context, session: Session) -> None:
|
||||
row = session.scalar(select(ResourceAccessToken).where(ResourceAccessToken.id == str(subject.token_id)))
|
||||
if row is None:
|
||||
raise Unauthorized("invalid_bearer")
|
||||
tenant = session.get(Tenant, row.tenant_id)
|
||||
if tenant is None or tenant.status != TenantStatus.NORMAL:
|
||||
raise Forbidden("workspace unavailable")
|
||||
workspace_id = ctx.view_args.get("workspace_id") or request.args.get("workspace_id")
|
||||
if workspace_id and workspace_id != str(tenant.id):
|
||||
raise Forbidden("resource_not_authorized")
|
||||
ctx._workspace = tenant
|
||||
ctx.resource_app_ids = frozenset(
|
||||
session.scalars(
|
||||
select(App.id)
|
||||
.join(ResourceAccessTokenRelation, ResourceAccessTokenRelation.app_id == App.id)
|
||||
.where(
|
||||
ResourceAccessTokenRelation.token_id == row.id,
|
||||
ResourceAccessTokenRelation.resource_type == ResourceAccessTokenResourceType.APP,
|
||||
App.tenant_id == tenant.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
app_id = ctx.view_args.get("app_id")
|
||||
if app_id:
|
||||
try:
|
||||
app_id = str(uuid.UUID(app_id))
|
||||
except ValueError:
|
||||
raise Forbidden("resource_not_authorized")
|
||||
if app_id not in ctx.resource_app_ids:
|
||||
raise Forbidden("resource_not_authorized")
|
||||
app = session.scalar(select(App).where(App.id == app_id, App.tenant_id == tenant.id))
|
||||
if app is None or app.status != AppStatus.NORMAL or not app.enable_api:
|
||||
raise Forbidden("resource_not_authorized")
|
||||
ctx._app = app
|
||||
row.last_used_at = naive_utc_now()
|
||||
@@ -11,11 +11,18 @@ from configs import dify_config
|
||||
from controllers.openapi.auth.context import Context
|
||||
from controllers.openapi.auth.pipelines import pipeline_for_subject
|
||||
from controllers.openapi.auth.requirements import assert_license_valid
|
||||
from controllers.openapi.auth.resource_access import authenticate_resource_token
|
||||
from controllers.openapi.auth.spec import EndpointSpec
|
||||
from controllers.openapi.auth.subjects import subject_from_auth
|
||||
from core.db.session_factory import session_factory
|
||||
from enums import DeploymentEdition
|
||||
from libs.oauth_bearer import InvalidBearerError, assert_bearer_feature_enabled, extract_bearer, get_authenticator
|
||||
from libs.oauth_bearer import (
|
||||
InvalidBearerError,
|
||||
TokenType,
|
||||
assert_bearer_feature_enabled,
|
||||
extract_bearer,
|
||||
get_authenticator,
|
||||
)
|
||||
|
||||
|
||||
class AuthRouter:
|
||||
@@ -34,21 +41,24 @@ class AuthRouter:
|
||||
answers 404 before anything reveals whether the bearer was valid, and
|
||||
an enterprise deployment's licence answers 403 before the missing-bearer
|
||||
401. The licence is a fact about the deployment, not the route or the
|
||||
caller, so this is the one place it is checked. The bearer feature flag
|
||||
is the same kind of fact and answers 503 next.
|
||||
caller, so this is the one place it is checked. The OAuth feature flag
|
||||
answers 503 before OAuth authentication, but does not gate independent
|
||||
resource credentials. Missing or unknown credentials still receive 503
|
||||
when OAuth is disabled.
|
||||
"""
|
||||
if spec.edition is not None and dify_config.DEPLOYMENT_EDITION not in spec.edition:
|
||||
raise NotFound()
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE:
|
||||
assert_license_valid()
|
||||
assert_bearer_feature_enabled()
|
||||
|
||||
token = extract_bearer(request)
|
||||
is_resource_token = token is not None and TokenType.for_token(token) is TokenType.RESOURCE_ACCESS
|
||||
if not is_resource_token:
|
||||
assert_bearer_feature_enabled()
|
||||
if not token:
|
||||
raise Unauthorized("bearer required")
|
||||
|
||||
try:
|
||||
auth = get_authenticator().authenticate(token)
|
||||
auth = authenticate_resource_token(token) if is_resource_token else get_authenticator().authenticate(token)
|
||||
except InvalidBearerError:
|
||||
# One answer for every rejection reason - unknown prefix, no live row,
|
||||
# expired - so a caller cannot probe which one it hit. Same reasoning as
|
||||
|
||||
@@ -146,5 +146,28 @@ class ExternalSsoSubject(Subject):
|
||||
return str(account.id) if account is not None else None
|
||||
|
||||
|
||||
class ResourceAccessSubject(Subject):
|
||||
subject_type = SubjectType.RESOURCE_ACCESS
|
||||
caller_role = CreatorUserRole.END_USER
|
||||
webapp_modes = frozenset[WebAppAccessMode]()
|
||||
|
||||
@override
|
||||
def resolve_caller(self, ctx: Context, session: Session) -> EndUser:
|
||||
return EndUserService.get_or_create_end_user_by_type(
|
||||
EndUserType.OPENAPI,
|
||||
tenant_id=str(ctx.workspace.id),
|
||||
app_id=str(ctx.app.id),
|
||||
user_id=f"resource-token:{self.token_id}",
|
||||
)
|
||||
|
||||
@override
|
||||
def mounts_caller(self, ctx: Context) -> bool:
|
||||
return route_has_app(ctx)
|
||||
|
||||
@override
|
||||
def webapp_user_id(self, session: Session) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def subject_from_auth(auth: AuthContext) -> Subject:
|
||||
return _SUBJECT_CLASSES[auth.subject_type](auth)
|
||||
|
||||
@@ -25,7 +25,7 @@ from controllers.openapi.auth.requirements import (
|
||||
CheckSubject,
|
||||
CheckWorkspaceMember,
|
||||
)
|
||||
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject
|
||||
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject, ResourceAccessSubject
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.file_fields import FileResponse
|
||||
from libs.oauth_bearer import Scope
|
||||
@@ -46,7 +46,7 @@ class AppFileUploadApi(Resource):
|
||||
)
|
||||
@endpoint(
|
||||
requirements=(
|
||||
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject)),
|
||||
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject, ResourceAccessSubject)),
|
||||
CheckAppApiEnabled(),
|
||||
CheckWorkspaceMember(),
|
||||
CheckScope(Scope.APPS_RUN),
|
||||
|
||||
@@ -27,7 +27,7 @@ from controllers.openapi.auth.requirements import (
|
||||
CheckWorkspaceMember,
|
||||
Requirement,
|
||||
)
|
||||
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject, Subject
|
||||
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject, ResourceAccessSubject, Subject
|
||||
from core.db.session_factory import session_factory
|
||||
from core.workflow.human_input_policy import HumanInputSurface, is_recipient_type_allowed_for_surface
|
||||
from extensions.ext_database import db
|
||||
@@ -87,7 +87,7 @@ def _ensure_form_belongs_to_app(form, app_model: App) -> None:
|
||||
class OpenApiWorkflowHumanInputFormApi(Resource):
|
||||
@endpoint(
|
||||
requirements=(
|
||||
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject)),
|
||||
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject, ResourceAccessSubject)),
|
||||
CheckAppApiEnabled(),
|
||||
CheckWorkspaceMember(),
|
||||
CheckScope(Scope.APPS_RUN),
|
||||
@@ -112,7 +112,7 @@ class OpenApiWorkflowHumanInputFormApi(Resource):
|
||||
class OpenApiWorkflowHumanInputFormSubmitApi(Resource):
|
||||
@endpoint(
|
||||
requirements=(
|
||||
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject)),
|
||||
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject, ResourceAccessSubject)),
|
||||
CheckAppApiEnabled(),
|
||||
CheckWorkspaceMember(),
|
||||
CheckScope(Scope.APPS_RUN),
|
||||
|
||||
@@ -30,7 +30,7 @@ from controllers.openapi.auth.requirements import (
|
||||
CheckSubject,
|
||||
CheckWorkspaceMember,
|
||||
)
|
||||
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject
|
||||
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject, ResourceAccessSubject
|
||||
from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator
|
||||
from core.app.apps.base_app_generator import BaseAppGenerator
|
||||
from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter
|
||||
@@ -55,7 +55,7 @@ class OpenApiWorkflowEventsApi(Resource):
|
||||
@openapi_ns.doc(params=query_params_from_model(WorkflowEventsQuery))
|
||||
@endpoint(
|
||||
requirements=(
|
||||
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject)),
|
||||
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject, ResourceAccessSubject)),
|
||||
CheckAppApiEnabled(),
|
||||
CheckWorkspaceMember(),
|
||||
CheckScope(Scope.APPS_RUN),
|
||||
|
||||
@@ -43,7 +43,7 @@ from controllers.openapi.auth.requirements import (
|
||||
CheckWorkspaceMember,
|
||||
CheckWorkspaceRole,
|
||||
)
|
||||
from controllers.openapi.auth.subjects import AccountSubject
|
||||
from controllers.openapi.auth.subjects import AccountSubject, ResourceAccessSubject
|
||||
from enums import DeploymentEdition
|
||||
from libs.oauth_bearer import Scope
|
||||
from models import Account, Tenant, TenantAccountJoin
|
||||
@@ -88,10 +88,19 @@ def _check_member_invite_quota(tenant_id: str) -> None:
|
||||
@openapi_ns.route("/workspaces")
|
||||
class WorkspacesApi(Resource):
|
||||
@endpoint(
|
||||
requirements=(CheckSubject(allowed=(AccountSubject,)), CheckScope(Scope.WORKSPACE_READ)),
|
||||
requirements=(CheckSubject(allowed=(AccountSubject, ResourceAccessSubject)), CheckScope(Scope.WORKSPACE_READ)),
|
||||
returns=(200, WorkspaceListResponse, "Workspace list"),
|
||||
)
|
||||
def get(self, ctx: Context):
|
||||
if isinstance(ctx.subject, ResourceAccessSubject):
|
||||
tenant = ctx.workspace
|
||||
return WorkspaceListResponse(
|
||||
workspaces=[
|
||||
WorkspaceSummaryResponse(
|
||||
id=str(tenant.id), name=tenant.name, role="", status=tenant.status, current=True
|
||||
)
|
||||
]
|
||||
)
|
||||
rows = TenantService.get_workspaces_for_account(str(ctx.subject.account_id), session=ctx.session)
|
||||
|
||||
return WorkspaceListResponse(workspaces=list(starmap(_workspace_summary, rows)))
|
||||
|
||||
@@ -34,6 +34,7 @@ from services import dataset_api_key_service
|
||||
from services.api_token_service import ApiTokenCache, fetch_token_with_single_flight, record_token_usage
|
||||
from services.end_user_service import EndUserService
|
||||
from services.feature_service import FeatureService
|
||||
from services.resource_access_token_service import ResourceAccessTokenService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -110,9 +111,16 @@ def validate_app_token[**P, R](
|
||||
def decorator(view_func: Callable[P, R]) -> Callable[P, R]:
|
||||
@wraps(view_func)
|
||||
def decorated_view(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||
api_token = validate_and_get_api_token("app")
|
||||
|
||||
app_model = db.session.get(App, api_token.app_id)
|
||||
auth_token = peek_service_api_bearer_token()
|
||||
if auth_token and ResourceAccessTokenService.is_resource_access_token(auth_token):
|
||||
app_model = ResourceAccessTokenService.resolve_app_for_service_api(
|
||||
token=auth_token,
|
||||
requested_app_id=request.headers.get("X-Dify-App-ID"),
|
||||
session=db.session(),
|
||||
)
|
||||
else:
|
||||
api_token = validate_and_get_api_token("app")
|
||||
app_model = db.session.get(App, api_token.app_id)
|
||||
if not app_model:
|
||||
raise Forbidden("The app no longer exists.")
|
||||
|
||||
@@ -318,8 +326,6 @@ def validate_dataset_token[R](view: Callable[..., R]) -> Callable[..., R]:
|
||||
|
||||
@wraps(view)
|
||||
def decorated(*args: object, **kwargs: object) -> R:
|
||||
api_token = validate_and_get_api_token("dataset")
|
||||
|
||||
# Flask may pass URL path parameters positionally, so inspect both kwargs and args.
|
||||
dataset_id = kwargs.get("dataset_id")
|
||||
|
||||
@@ -332,17 +338,28 @@ def validate_dataset_token[R](view: Callable[..., R]) -> Callable[..., R]:
|
||||
except Exception:
|
||||
logger.exception("Failed to parse dataset_id from positional args")
|
||||
|
||||
# Per-knowledge-base scoping is expressed by DatasetApiTokenBinding rows:
|
||||
# no rows -> the key can reach every dataset in its tenant (default / back-compat)
|
||||
# N rows -> the key is limited to exactly those datasets
|
||||
# A bound key may only call endpoints carrying one of its dataset ids; endpoints
|
||||
# without a dataset id (e.g. list/create datasets) are rejected. The set is queried
|
||||
# per request (not cached) so scope changes take effect immediately.
|
||||
# db.session is Flask-SQLAlchemy's scoped_session proxy; cast so the plain-Session
|
||||
# typed helper accepts it (runtime proxies every Session method through unchanged).
|
||||
bound_dataset_ids = dataset_api_key_service.get_bound_dataset_ids(cast(Session, db.session), api_token.id)
|
||||
if bound_dataset_ids and (not dataset_id or str(dataset_id) not in bound_dataset_ids):
|
||||
raise Forbidden("The API key is not authorized to access this knowledge base.")
|
||||
auth_token = peek_service_api_bearer_token()
|
||||
if auth_token and ResourceAccessTokenService.is_resource_access_token(auth_token):
|
||||
tenant_id = ResourceAccessTokenService.resolve_tenant_for_dataset_service_api(
|
||||
token=auth_token,
|
||||
dataset_id=str(dataset_id) if dataset_id else None,
|
||||
session=db.session(),
|
||||
)
|
||||
else:
|
||||
api_token = validate_and_get_api_token("dataset")
|
||||
tenant_id = api_token.tenant_id
|
||||
|
||||
# Per-knowledge-base scoping is expressed by DatasetApiTokenBinding rows:
|
||||
# no rows -> the key can reach every dataset in its tenant (default / back-compat)
|
||||
# N rows -> the key is limited to exactly those datasets
|
||||
# A bound key may only call endpoints carrying one of its dataset ids; endpoints
|
||||
# without a dataset id (e.g. list/create datasets) are rejected. The set is queried
|
||||
# per request (not cached) so scope changes take effect immediately.
|
||||
# db.session is Flask-SQLAlchemy's scoped_session proxy; cast so the plain-Session
|
||||
# typed helper accepts it (runtime proxies every Session method through unchanged).
|
||||
bound_dataset_ids = dataset_api_key_service.get_bound_dataset_ids(cast(Session, db.session), api_token.id)
|
||||
if bound_dataset_ids and (not dataset_id or str(dataset_id) not in bound_dataset_ids):
|
||||
raise Forbidden("The API key is not authorized to access this knowledge base.")
|
||||
|
||||
if dataset_id:
|
||||
dataset_id = str(dataset_id)
|
||||
@@ -350,7 +367,7 @@ def validate_dataset_token[R](view: Callable[..., R]) -> Callable[..., R]:
|
||||
select(Dataset)
|
||||
.where(
|
||||
Dataset.id == dataset_id,
|
||||
Dataset.tenant_id == api_token.tenant_id,
|
||||
Dataset.tenant_id == tenant_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
@@ -361,7 +378,7 @@ def validate_dataset_token[R](view: Callable[..., R]) -> Callable[..., R]:
|
||||
|
||||
tenant_account_join = db.session.execute(
|
||||
select(Tenant, TenantAccountJoin).where(
|
||||
Tenant.id == api_token.tenant_id,
|
||||
Tenant.id == tenant_id,
|
||||
TenantAccountJoin.tenant_id == Tenant.id,
|
||||
TenantAccountJoin.role.in_(["owner"]),
|
||||
Tenant.status == TenantStatus.NORMAL,
|
||||
@@ -383,9 +400,9 @@ def validate_dataset_token[R](view: Callable[..., R]) -> Callable[..., R]:
|
||||
if expects_bound_instance:
|
||||
if not args:
|
||||
raise TypeError("validate_dataset_token expected a bound resource instance.")
|
||||
return view(args[0], api_token.tenant_id, *args[1:], **kwargs)
|
||||
return view(args[0], tenant_id, *args[1:], **kwargs)
|
||||
|
||||
return view(api_token.tenant_id, *args, **kwargs)
|
||||
return view(tenant_id, *args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
||||
@@ -401,15 +418,7 @@ def validate_and_get_api_token(scope: str | None = None):
|
||||
The last_used_at field is updated asynchronously via Celery task
|
||||
to avoid blocking the request.
|
||||
"""
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if auth_header is None or " " not in auth_header:
|
||||
raise Unauthorized("Authorization header must be provided and start with 'Bearer'")
|
||||
|
||||
auth_scheme, auth_token = auth_header.split(None, 1)
|
||||
auth_scheme = auth_scheme.lower()
|
||||
|
||||
if auth_scheme != "bearer":
|
||||
raise Unauthorized("Authorization scheme must be 'Bearer'")
|
||||
auth_token = extract_service_api_bearer_token()
|
||||
|
||||
# Try to get token from cache first
|
||||
# Returns a CachedApiToken (plain Python object), not a SQLAlchemy model
|
||||
@@ -425,6 +434,27 @@ def validate_and_get_api_token(scope: str | None = None):
|
||||
return fetch_token_with_single_flight(auth_token, scope)
|
||||
|
||||
|
||||
def extract_service_api_bearer_token() -> str:
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if auth_header is None or " " not in auth_header:
|
||||
raise Unauthorized("Authorization header must be provided and start with 'Bearer'")
|
||||
|
||||
auth_scheme, auth_token = auth_header.split(None, 1)
|
||||
if auth_scheme.lower() != "bearer":
|
||||
raise Unauthorized("Authorization scheme must be 'Bearer'")
|
||||
return auth_token
|
||||
|
||||
|
||||
def peek_service_api_bearer_token() -> str | None:
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if auth_header is None or " " not in auth_header:
|
||||
return None
|
||||
auth_scheme, auth_token = auth_header.split(None, 1)
|
||||
if auth_scheme.lower() != "bearer":
|
||||
return None
|
||||
return auth_token
|
||||
|
||||
|
||||
class DatasetApiResource(Resource):
|
||||
__apidoc__ = {"responses": DATASET_TOKEN_AUTH_RESPONSES}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ class SubjectType(StrEnum):
|
||||
# not is keyed by its external issuer instead.
|
||||
bound_to_account: bool
|
||||
|
||||
RESOURCE_ACCESS = ("resource_access", frozenset({Scope.WORKSPACE_READ, Scope.APPS_READ, Scope.APPS_RUN}), False)
|
||||
ACCOUNT = ("account", frozenset({Scope.FULL}), True)
|
||||
EXTERNAL_SSO = ("external_sso", frozenset({Scope.APPS_RUN, Scope.APPS_READ_PERMITTED_EXTERNAL}), False)
|
||||
|
||||
@@ -72,6 +73,7 @@ class TokenType(StrEnum):
|
||||
prefix: str
|
||||
subject: SubjectType
|
||||
|
||||
RESOURCE_ACCESS = ("resource_access", "sk-", SubjectType.RESOURCE_ACCESS)
|
||||
OAUTH_ACCOUNT = ("oauth_account", "dfoa_", SubjectType.ACCOUNT)
|
||||
OAUTH_EXTERNAL_SSO = ("oauth_external_sso", "dfoe_", SubjectType.EXTERNAL_SSO)
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""add resource access token tables
|
||||
|
||||
Revision ID: a8c9e2f1b704
|
||||
Revises: d8e4a6b1c902
|
||||
Create Date: 2026-08-18 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
import models.types
|
||||
|
||||
|
||||
def _is_pg(conn):
|
||||
return conn.dialect.name == "postgresql"
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "a8c9e2f1b704"
|
||||
down_revision = "d8e4a6b1c902"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
|
||||
if _is_pg(conn):
|
||||
op.create_table(
|
||||
"resource_access_tokens",
|
||||
sa.Column("id", postgresql.UUID(), nullable=False),
|
||||
sa.Column("tenant_id", postgresql.UUID(), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("track_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("token", sa.String(length=255), nullable=False),
|
||||
sa.Column("created_by", postgresql.UUID(), nullable=False),
|
||||
sa.Column("last_used_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP(0)"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name="resource_access_token_pkey"),
|
||||
)
|
||||
op.create_table(
|
||||
"resource_access_tokens_relation",
|
||||
sa.Column("id", postgresql.UUID(), nullable=False),
|
||||
sa.Column("token_id", postgresql.UUID(), nullable=False),
|
||||
sa.Column("resource_type", sa.String(length=16), nullable=False),
|
||||
sa.Column("app_id", postgresql.UUID(), nullable=True),
|
||||
sa.Column("dataset_id", postgresql.UUID(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP(0)"), nullable=False),
|
||||
sa.CheckConstraint(
|
||||
"(resource_type = 'app' AND app_id IS NOT NULL AND dataset_id IS NULL) OR "
|
||||
"(resource_type = 'knowledge' AND dataset_id IS NOT NULL AND app_id IS NULL)",
|
||||
name="resource_access_token_relation_resource_check",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name="resource_access_token_relation_pkey"),
|
||||
)
|
||||
else:
|
||||
op.create_table(
|
||||
"resource_access_tokens",
|
||||
sa.Column("id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("track_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("token", sa.String(length=255), nullable=False),
|
||||
sa.Column("created_by", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("last_used_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name="resource_access_token_pkey"),
|
||||
)
|
||||
op.create_table(
|
||||
"resource_access_tokens_relation",
|
||||
sa.Column("id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("token_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("resource_type", sa.String(length=16), nullable=False),
|
||||
sa.Column("app_id", models.types.StringUUID(), nullable=True),
|
||||
sa.Column("dataset_id", models.types.StringUUID(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.CheckConstraint(
|
||||
"(resource_type = 'app' AND app_id IS NOT NULL AND dataset_id IS NULL) OR "
|
||||
"(resource_type = 'knowledge' AND dataset_id IS NOT NULL AND app_id IS NULL)",
|
||||
name="resource_access_token_relation_resource_check",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name="resource_access_token_relation_pkey"),
|
||||
)
|
||||
|
||||
with op.batch_alter_table("resource_access_tokens", schema=None) as batch_op:
|
||||
batch_op.create_index("resource_access_token_tenant_idx", ["tenant_id"], unique=False)
|
||||
batch_op.create_index("resource_access_token_token_idx", ["token"], unique=True)
|
||||
batch_op.create_index("resource_access_token_track_id_idx", ["track_id"], unique=True)
|
||||
|
||||
with op.batch_alter_table("resource_access_tokens_relation", schema=None) as batch_op:
|
||||
batch_op.create_index("resource_access_token_relation_token_idx", ["token_id"], unique=False)
|
||||
batch_op.create_index("resource_access_token_relation_app_idx", ["app_id"], unique=False)
|
||||
batch_op.create_index("resource_access_token_relation_dataset_idx", ["dataset_id"], unique=False)
|
||||
batch_op.create_unique_constraint(
|
||||
"resource_access_token_relation_app_unique",
|
||||
["token_id", "resource_type", "app_id"],
|
||||
)
|
||||
batch_op.create_unique_constraint(
|
||||
"resource_access_token_relation_dataset_unique",
|
||||
["token_id", "resource_type", "dataset_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("resource_access_tokens_relation")
|
||||
op.drop_table("resource_access_tokens")
|
||||
@@ -0,0 +1,26 @@
|
||||
"""restore deploy/dev alembic revision c8e7f6a5b4d3
|
||||
|
||||
Revision ID: c8e7f6a5b4d3
|
||||
Revises: 89919253ca7a
|
||||
Create Date: 2026-08-20 15:45:00.000000
|
||||
|
||||
The deploy/dev database is stamped at this revision. The original file
|
||||
was an empty merge of skill/debug heads and later disappeared when
|
||||
deploy/dev was rewritten. Keep the revision id so flask db upgrade can
|
||||
locate the database, and parent it at remove_agent_drive — the last
|
||||
shared revision that was actually applied — so later migrations still run.
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "c8e7f6a5b4d3"
|
||||
down_revision = "89919253ca7a"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,21 @@
|
||||
"""merge c8e7f6a5b4d3 onto current deploy/dev head
|
||||
|
||||
Revision ID: ae0c8e7b4d31
|
||||
Revises: c8e7f6a5b4d3, 5578e028b2f2
|
||||
Create Date: 2026-09-01 17:48:00.000000
|
||||
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "ae0c8e7b4d31"
|
||||
down_revision = ("c8e7f6a5b4d3", "5578e028b2f2")
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,21 @@
|
||||
"""merge deploy/dev and main migration heads
|
||||
|
||||
Revision ID: f2a7c9e4b610
|
||||
Revises: ae0c8e7b4d31, d8e4a6b1c902
|
||||
Create Date: 2026-09-17 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "f2a7c9e4b610"
|
||||
down_revision = ("ae0c8e7b4d31", "d8e4a6b1c902")
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,21 @@
|
||||
"""merge resource access token and deploy/dev migration heads
|
||||
|
||||
Revision ID: b6d4e8f2a901
|
||||
Revises: a8c9e2f1b704, f2a7c9e4b610
|
||||
Create Date: 2026-09-20 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "b6d4e8f2a901"
|
||||
down_revision = ("a8c9e2f1b704", "f2a7c9e4b610")
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -113,6 +113,11 @@ from .provider import (
|
||||
TenantDefaultModel,
|
||||
TenantPreferredModelProvider,
|
||||
)
|
||||
from .resource_access_token import (
|
||||
ResourceAccessToken,
|
||||
ResourceAccessTokenRelation,
|
||||
ResourceAccessTokenResourceType,
|
||||
)
|
||||
from .skill import AgentSkillBinding, Skill, SkillDraftFile, SkillFileKind, SkillFileStorage, SkillVersion
|
||||
from .snippet import CustomizedSnippet, SnippetType
|
||||
from .source import DataSourceApiKeyAuthBinding, DataSourceOauthBinding
|
||||
@@ -249,6 +254,9 @@ __all__ = [
|
||||
"ProviderQuotaType",
|
||||
"ProviderType",
|
||||
"RecommendedApp",
|
||||
"ResourceAccessToken",
|
||||
"ResourceAccessTokenRelation",
|
||||
"ResourceAccessTokenResourceType",
|
||||
"SavedMessage",
|
||||
"Site",
|
||||
"Skill",
|
||||
|
||||
80
api/models/resource_access_token.py
Normal file
80
api/models/resource_access_token.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from uuid import uuid4
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import DateTime, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
|
||||
from .base import Base
|
||||
from .types import EnumText, StringUUID
|
||||
|
||||
|
||||
class ResourceAccessTokenResourceType(StrEnum):
|
||||
APP = "app"
|
||||
KNOWLEDGE = "knowledge"
|
||||
|
||||
|
||||
class ResourceAccessToken(Base):
|
||||
__tablename__ = "resource_access_tokens"
|
||||
__table_args__ = (
|
||||
sa.PrimaryKeyConstraint("id", name="resource_access_token_pkey"),
|
||||
sa.Index("resource_access_token_tenant_idx", "tenant_id"),
|
||||
sa.Index("resource_access_token_token_idx", "token", unique=True),
|
||||
sa.Index("resource_access_token_track_id_idx", "track_id", unique=True),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuid4()))
|
||||
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
track_id: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
token: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
created_by: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
default=naive_utc_now,
|
||||
server_default=func.current_timestamp(),
|
||||
)
|
||||
|
||||
|
||||
class ResourceAccessTokenRelation(Base):
|
||||
__tablename__ = "resource_access_tokens_relation"
|
||||
__table_args__ = (
|
||||
sa.PrimaryKeyConstraint("id", name="resource_access_token_relation_pkey"),
|
||||
sa.CheckConstraint(
|
||||
"(resource_type = 'app' AND app_id IS NOT NULL AND dataset_id IS NULL) OR "
|
||||
"(resource_type = 'knowledge' AND dataset_id IS NOT NULL AND app_id IS NULL)",
|
||||
name="resource_access_token_relation_resource_check",
|
||||
),
|
||||
sa.Index("resource_access_token_relation_token_idx", "token_id"),
|
||||
sa.Index("resource_access_token_relation_app_idx", "app_id"),
|
||||
sa.Index("resource_access_token_relation_dataset_idx", "dataset_id"),
|
||||
sa.UniqueConstraint("token_id", "resource_type", "app_id", name="resource_access_token_relation_app_unique"),
|
||||
sa.UniqueConstraint(
|
||||
"token_id",
|
||||
"resource_type",
|
||||
"dataset_id",
|
||||
name="resource_access_token_relation_dataset_unique",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuid4()))
|
||||
token_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
resource_type: Mapped[ResourceAccessTokenResourceType] = mapped_column(
|
||||
EnumText(ResourceAccessTokenResourceType, length=16),
|
||||
nullable=False,
|
||||
)
|
||||
app_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
dataset_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
default=naive_utc_now,
|
||||
server_default=func.current_timestamp(),
|
||||
)
|
||||
@@ -51,6 +51,7 @@ from services.enterprise import rbac_service as enterprise_rbac_service
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
from services.openapi.visibility import apply_openapi_gate, is_openapi_visible
|
||||
from services.rbac_agent_access_service import initialize_agent_rbac_access
|
||||
from services.resource_access_token_service import ResourceAccessTokenService
|
||||
from services.system_feature_service import SystemFeatureService
|
||||
from services.tag_service import TagService
|
||||
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
|
||||
@@ -1173,6 +1174,7 @@ class AppService:
|
||||
tenant_id=app.tenant_id,
|
||||
app_id=app.id,
|
||||
)
|
||||
ResourceAccessTokenService.delete_relations_for_app(app_id=app.id, session=session)
|
||||
session.delete(app)
|
||||
session.commit()
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ from services.external_knowledge_service import ExternalDatasetService
|
||||
from services.feature_service import FeatureService
|
||||
from services.file_service import FileService
|
||||
from services.rag_pipeline.rag_pipeline import RagPipelineService
|
||||
from services.resource_access_token_service import ResourceAccessTokenService
|
||||
from services.tag_service import TagService
|
||||
from services.vector_service import VectorService
|
||||
from tasks.add_document_to_index_task import add_document_to_index_task
|
||||
@@ -1365,6 +1366,7 @@ class DatasetService:
|
||||
# silently degrade to unrestricted (access-all) once its last binding is gone.
|
||||
dataset_api_key_service.delete_keys_scoped_only_to(session, str(dataset.id))
|
||||
|
||||
ResourceAccessTokenService.delete_relations_for_dataset(dataset_id=dataset.id, session=session)
|
||||
session.delete(dataset)
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
467
api/services/resource_access_token_service.py
Normal file
467
api/services/resource_access_token_service.py
Normal file
@@ -0,0 +1,467 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import BadRequest, Forbidden, NotFound, Unauthorized
|
||||
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.dataset import Dataset
|
||||
from models.model import App
|
||||
from models.resource_access_token import (
|
||||
ResourceAccessToken,
|
||||
ResourceAccessTokenRelation,
|
||||
ResourceAccessTokenResourceType,
|
||||
)
|
||||
|
||||
TOKEN_PREFIX = "sk-"
|
||||
MASK_PREFIX_LENGTH = 8
|
||||
MASK_SUFFIX_LENGTH = 4
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResourceAccessTokenResource:
|
||||
type: ResourceAccessTokenResourceType
|
||||
id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResourceAccessTokenRow:
|
||||
token_id: str
|
||||
relation_id: str
|
||||
name: str
|
||||
track_id: str
|
||||
token: str | None
|
||||
masked_token: str
|
||||
resource_type: ResourceAccessTokenResourceType
|
||||
resource_id: str
|
||||
resource_name: str
|
||||
created_at: datetime
|
||||
last_used_at: datetime | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResourceAccessTokenCreateResult:
|
||||
token_id: str
|
||||
token: str
|
||||
rows: list[ResourceAccessTokenRow]
|
||||
|
||||
|
||||
class ResourceAccessTokenService:
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
created_by: str,
|
||||
name: str,
|
||||
resources: list[ResourceAccessTokenResource],
|
||||
session: Session,
|
||||
) -> ResourceAccessTokenCreateResult:
|
||||
name = cls._normalize_name(name)
|
||||
if not resources:
|
||||
raise BadRequest("At least one resource is required.")
|
||||
|
||||
token = ResourceAccessToken(
|
||||
tenant_id=tenant_id,
|
||||
name=name,
|
||||
track_id=cls._generate_track_id(session),
|
||||
token=cls._generate_token(session),
|
||||
created_by=created_by,
|
||||
)
|
||||
session.add(token)
|
||||
session.flush()
|
||||
|
||||
seen: set[tuple[ResourceAccessTokenResourceType, str]] = set()
|
||||
for resource in resources:
|
||||
key = (resource.type, resource.id)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
cls._ensure_resource_available(tenant_id=tenant_id, resource=resource, session=session)
|
||||
relation = ResourceAccessTokenRelation(
|
||||
token_id=token.id,
|
||||
resource_type=resource.type,
|
||||
app_id=resource.id if resource.type == ResourceAccessTokenResourceType.APP else None,
|
||||
dataset_id=resource.id if resource.type == ResourceAccessTokenResourceType.KNOWLEDGE else None,
|
||||
)
|
||||
session.add(relation)
|
||||
|
||||
session.commit()
|
||||
rows = cls.list_rows(tenant_id=tenant_id, token_id=token.id, include_token=True, session=session)
|
||||
return ResourceAccessTokenCreateResult(token_id=token.id, token=token.token, rows=rows)
|
||||
|
||||
@classmethod
|
||||
def list_rows(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
session: Session,
|
||||
page: int = 1,
|
||||
limit: int = 20,
|
||||
token_id: str | None = None,
|
||||
include_token: bool = False,
|
||||
) -> list[ResourceAccessTokenRow]:
|
||||
stmt = (
|
||||
select(ResourceAccessToken, ResourceAccessTokenRelation, App.name, Dataset.name)
|
||||
.join(ResourceAccessTokenRelation, ResourceAccessTokenRelation.token_id == ResourceAccessToken.id)
|
||||
.outerjoin(App, App.id == ResourceAccessTokenRelation.app_id)
|
||||
.outerjoin(Dataset, Dataset.id == ResourceAccessTokenRelation.dataset_id)
|
||||
.where(ResourceAccessToken.tenant_id == tenant_id)
|
||||
.order_by(ResourceAccessToken.created_at.desc(), ResourceAccessTokenRelation.created_at.desc())
|
||||
)
|
||||
if token_id:
|
||||
stmt = stmt.where(ResourceAccessToken.id == token_id)
|
||||
else:
|
||||
token_ids = (
|
||||
select(ResourceAccessToken.id)
|
||||
.where(ResourceAccessToken.tenant_id == tenant_id)
|
||||
.order_by(ResourceAccessToken.created_at.desc())
|
||||
.offset(max(page - 1, 0) * limit)
|
||||
.limit(limit)
|
||||
)
|
||||
stmt = stmt.where(ResourceAccessToken.id.in_(token_ids))
|
||||
|
||||
return [
|
||||
cls._row_from_models(token, relation, app_name, dataset_name, include_token=include_token)
|
||||
for token, relation, app_name, dataset_name in session.execute(stmt).all()
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def count_rows(cls, *, tenant_id: str, session: Session) -> int:
|
||||
return (
|
||||
session.scalar(
|
||||
select(func.count(ResourceAccessToken.id)).where(ResourceAccessToken.tenant_id == tenant_id)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def update(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
token_id: str,
|
||||
name: str,
|
||||
resources: list[ResourceAccessTokenResource] | None = None,
|
||||
session: Session,
|
||||
) -> list[ResourceAccessTokenRow]:
|
||||
token = cls._get_token(tenant_id=tenant_id, token_id=token_id, session=session)
|
||||
token.name = cls._normalize_name(name)
|
||||
if resources is not None:
|
||||
if not resources:
|
||||
raise BadRequest("At least one resource is required.")
|
||||
|
||||
seen: set[tuple[ResourceAccessTokenResourceType, str]] = set()
|
||||
desired_resources: list[ResourceAccessTokenResource] = []
|
||||
for resource in resources:
|
||||
key = (resource.type, resource.id)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
cls._ensure_resource_available(tenant_id=tenant_id, resource=resource, session=session)
|
||||
desired_resources.append(resource)
|
||||
|
||||
existing_relations = session.scalars(
|
||||
select(ResourceAccessTokenRelation).where(ResourceAccessTokenRelation.token_id == token.id)
|
||||
).all()
|
||||
existing_by_key = {cls._relation_key(relation): relation for relation in existing_relations}
|
||||
desired_keys = {(resource.type, resource.id) for resource in desired_resources}
|
||||
|
||||
for key, relation in existing_by_key.items():
|
||||
if key not in desired_keys:
|
||||
session.delete(relation)
|
||||
|
||||
for resource in desired_resources:
|
||||
key = (resource.type, resource.id)
|
||||
if key in existing_by_key:
|
||||
continue
|
||||
session.add(
|
||||
ResourceAccessTokenRelation(
|
||||
token_id=token.id,
|
||||
resource_type=resource.type,
|
||||
app_id=resource.id if resource.type == ResourceAccessTokenResourceType.APP else None,
|
||||
dataset_id=resource.id if resource.type == ResourceAccessTokenResourceType.KNOWLEDGE else None,
|
||||
)
|
||||
)
|
||||
|
||||
session.commit()
|
||||
return cls.list_rows(tenant_id=tenant_id, token_id=token_id, session=session)
|
||||
|
||||
@classmethod
|
||||
def delete_relation(cls, *, tenant_id: str, token_id: str, relation_id: str, session: Session) -> None:
|
||||
token = cls._get_token(tenant_id=tenant_id, token_id=token_id, session=session)
|
||||
relation = session.scalar(
|
||||
select(ResourceAccessTokenRelation).where(
|
||||
ResourceAccessTokenRelation.id == relation_id,
|
||||
ResourceAccessTokenRelation.token_id == token.id,
|
||||
)
|
||||
)
|
||||
if relation is None:
|
||||
raise NotFound("Resource access token relation not found.")
|
||||
|
||||
session.execute(
|
||||
delete(ResourceAccessTokenRelation).where(ResourceAccessTokenRelation.token_id == token.id)
|
||||
)
|
||||
session.delete(token)
|
||||
session.commit()
|
||||
|
||||
@classmethod
|
||||
def delete_relations_for_app(cls, *, app_id: str, session: Session) -> None:
|
||||
token_ids = cls._delete_relations(
|
||||
resource_type=ResourceAccessTokenResourceType.APP,
|
||||
resource_id_field=ResourceAccessTokenRelation.app_id,
|
||||
resource_id=app_id,
|
||||
session=session,
|
||||
)
|
||||
cls._delete_orphan_tokens(token_ids=token_ids, session=session)
|
||||
|
||||
@classmethod
|
||||
def delete_relations_for_dataset(cls, *, dataset_id: str, session: Session) -> None:
|
||||
token_ids = cls._delete_relations(
|
||||
resource_type=ResourceAccessTokenResourceType.KNOWLEDGE,
|
||||
resource_id_field=ResourceAccessTokenRelation.dataset_id,
|
||||
resource_id=dataset_id,
|
||||
session=session,
|
||||
)
|
||||
cls._delete_orphan_tokens(token_ids=token_ids, session=session)
|
||||
|
||||
@classmethod
|
||||
def validate_app_access(cls, *, token: str, app: App, session: Session) -> ResourceAccessToken:
|
||||
access_token = cls._get_by_token(token=token, session=session)
|
||||
relation = session.scalar(
|
||||
select(ResourceAccessTokenRelation).where(
|
||||
ResourceAccessTokenRelation.token_id == access_token.id,
|
||||
ResourceAccessTokenRelation.resource_type == ResourceAccessTokenResourceType.APP,
|
||||
ResourceAccessTokenRelation.app_id == app.id,
|
||||
)
|
||||
)
|
||||
if relation is None:
|
||||
raise Forbidden("Resource access token is not allowed to access this app.")
|
||||
access_token.last_used_at = naive_utc_now()
|
||||
session.commit()
|
||||
return access_token
|
||||
|
||||
@classmethod
|
||||
def resolve_app_for_service_api(
|
||||
cls,
|
||||
*,
|
||||
token: str,
|
||||
requested_app_id: str | None,
|
||||
session: Session,
|
||||
) -> App:
|
||||
access_token = cls._get_by_token(token=token, session=session)
|
||||
stmt = select(ResourceAccessTokenRelation).where(
|
||||
ResourceAccessTokenRelation.token_id == access_token.id,
|
||||
ResourceAccessTokenRelation.resource_type == ResourceAccessTokenResourceType.APP,
|
||||
)
|
||||
if requested_app_id:
|
||||
stmt = stmt.where(ResourceAccessTokenRelation.app_id == requested_app_id)
|
||||
|
||||
relations = session.scalars(stmt).all()
|
||||
if not relations:
|
||||
raise Forbidden("Resource access token is not allowed to access this app.")
|
||||
if len(relations) > 1:
|
||||
raise BadRequest("App ID is required when a resource access token is bound to multiple apps.")
|
||||
|
||||
app_id = relations[0].app_id
|
||||
if app_id is None:
|
||||
raise Forbidden("Resource access token is not allowed to access this app.")
|
||||
app = session.get(App, app_id)
|
||||
if app is None:
|
||||
raise Forbidden("The app no longer exists.")
|
||||
access_token.last_used_at = naive_utc_now()
|
||||
session.commit()
|
||||
return app
|
||||
|
||||
@classmethod
|
||||
def validate_dataset_access(cls, *, token: str, dataset_id: str, session: Session) -> ResourceAccessToken:
|
||||
access_token = cls._get_by_token(token=token, session=session)
|
||||
relation = session.scalar(
|
||||
select(ResourceAccessTokenRelation).where(
|
||||
ResourceAccessTokenRelation.token_id == access_token.id,
|
||||
ResourceAccessTokenRelation.resource_type == ResourceAccessTokenResourceType.KNOWLEDGE,
|
||||
ResourceAccessTokenRelation.dataset_id == dataset_id,
|
||||
)
|
||||
)
|
||||
if relation is None:
|
||||
raise Forbidden("Resource access token is not allowed to access this knowledge base.")
|
||||
access_token.last_used_at = naive_utc_now()
|
||||
session.commit()
|
||||
return access_token
|
||||
|
||||
@classmethod
|
||||
def resolve_tenant_for_dataset_service_api(
|
||||
cls,
|
||||
*,
|
||||
token: str,
|
||||
dataset_id: str | None,
|
||||
session: Session,
|
||||
) -> str:
|
||||
access_token = cls._get_by_token(token=token, session=session)
|
||||
stmt = select(ResourceAccessTokenRelation).where(
|
||||
ResourceAccessTokenRelation.token_id == access_token.id,
|
||||
ResourceAccessTokenRelation.resource_type == ResourceAccessTokenResourceType.KNOWLEDGE,
|
||||
)
|
||||
if dataset_id:
|
||||
stmt = stmt.where(ResourceAccessTokenRelation.dataset_id == dataset_id)
|
||||
|
||||
relation = session.scalar(stmt.limit(1))
|
||||
if relation is None:
|
||||
raise Forbidden("Resource access token is not allowed to access this knowledge base.")
|
||||
access_token.last_used_at = naive_utc_now()
|
||||
session.commit()
|
||||
return access_token.tenant_id
|
||||
|
||||
@staticmethod
|
||||
def is_resource_access_token(token: str) -> bool:
|
||||
return token.startswith(TOKEN_PREFIX)
|
||||
|
||||
@staticmethod
|
||||
def mask_token(token: str) -> str:
|
||||
if len(token) <= MASK_PREFIX_LENGTH + MASK_SUFFIX_LENGTH:
|
||||
return token
|
||||
return f"{token[:MASK_PREFIX_LENGTH]}...{token[-MASK_SUFFIX_LENGTH:]}"
|
||||
|
||||
@classmethod
|
||||
def _get_by_token(cls, *, token: str, session: Session) -> ResourceAccessToken:
|
||||
access_token = session.scalar(select(ResourceAccessToken).where(ResourceAccessToken.token == token))
|
||||
if access_token is None:
|
||||
raise Unauthorized("Resource access token is invalid.")
|
||||
return access_token
|
||||
|
||||
@classmethod
|
||||
def _get_token(cls, *, tenant_id: str, token_id: str, session: Session) -> ResourceAccessToken:
|
||||
token = session.scalar(
|
||||
select(ResourceAccessToken).where(
|
||||
ResourceAccessToken.id == token_id,
|
||||
ResourceAccessToken.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
if token is None:
|
||||
raise NotFound("Resource access token not found.")
|
||||
return token
|
||||
|
||||
@staticmethod
|
||||
def _normalize_name(name: str) -> str:
|
||||
normalized = name.strip()
|
||||
if not normalized:
|
||||
raise BadRequest("Name is required.")
|
||||
return normalized
|
||||
|
||||
@classmethod
|
||||
def _generate_token(cls, session: Session) -> str:
|
||||
while True:
|
||||
value = f"{TOKEN_PREFIX}{uuid4()}"
|
||||
if not session.scalar(select(ResourceAccessToken.id).where(ResourceAccessToken.token == value)):
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def _generate_track_id(cls, session: Session) -> str:
|
||||
while True:
|
||||
value = uuid4().hex
|
||||
if not session.scalar(select(ResourceAccessToken.id).where(ResourceAccessToken.track_id == value)):
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _ensure_resource_available(
|
||||
*,
|
||||
tenant_id: str,
|
||||
resource: ResourceAccessTokenResource,
|
||||
session: Session,
|
||||
) -> None:
|
||||
if resource.type == ResourceAccessTokenResourceType.APP:
|
||||
app = session.scalar(select(App).where(App.id == resource.id, App.tenant_id == tenant_id))
|
||||
if app is None:
|
||||
raise NotFound("App not found.")
|
||||
if app.status != "normal" or not app.enable_api:
|
||||
raise Forbidden("App API access is not enabled.")
|
||||
return
|
||||
|
||||
dataset = session.scalar(select(Dataset).where(Dataset.id == resource.id, Dataset.tenant_id == tenant_id))
|
||||
if dataset is None:
|
||||
raise NotFound("Knowledge base not found.")
|
||||
if not dataset.enable_api:
|
||||
raise Forbidden("Knowledge base API access is not enabled.")
|
||||
|
||||
@staticmethod
|
||||
def _delete_relations(
|
||||
*,
|
||||
resource_type: ResourceAccessTokenResourceType,
|
||||
resource_id_field,
|
||||
resource_id: str,
|
||||
session: Session,
|
||||
) -> list[str]:
|
||||
token_ids = session.scalars(
|
||||
select(ResourceAccessTokenRelation.token_id).where(
|
||||
ResourceAccessTokenRelation.resource_type == resource_type,
|
||||
resource_id_field == resource_id,
|
||||
)
|
||||
).all()
|
||||
if token_ids:
|
||||
session.execute(
|
||||
delete(ResourceAccessTokenRelation).where(
|
||||
ResourceAccessTokenRelation.resource_type == resource_type,
|
||||
resource_id_field == resource_id,
|
||||
)
|
||||
)
|
||||
return list(token_ids)
|
||||
|
||||
@staticmethod
|
||||
def _delete_orphan_tokens(*, token_ids: list[str], session: Session) -> None:
|
||||
for token_id in set(token_ids):
|
||||
remaining_count = (
|
||||
session.scalar(
|
||||
select(func.count(ResourceAccessTokenRelation.id)).where(
|
||||
ResourceAccessTokenRelation.token_id == token_id,
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
if remaining_count == 0:
|
||||
session.execute(delete(ResourceAccessToken).where(ResourceAccessToken.id == token_id))
|
||||
|
||||
@staticmethod
|
||||
def _relation_key(
|
||||
relation: ResourceAccessTokenRelation,
|
||||
) -> tuple[ResourceAccessTokenResourceType, str]:
|
||||
resource_id = (
|
||||
relation.app_id
|
||||
if relation.resource_type == ResourceAccessTokenResourceType.APP
|
||||
else relation.dataset_id
|
||||
)
|
||||
assert resource_id is not None
|
||||
return relation.resource_type, resource_id
|
||||
|
||||
@classmethod
|
||||
def _row_from_models(
|
||||
cls,
|
||||
token: ResourceAccessToken,
|
||||
relation: ResourceAccessTokenRelation,
|
||||
app_name: str | None,
|
||||
dataset_name: str | None,
|
||||
*,
|
||||
include_token: bool,
|
||||
) -> ResourceAccessTokenRow:
|
||||
resource_id = (
|
||||
relation.app_id
|
||||
if relation.resource_type == ResourceAccessTokenResourceType.APP
|
||||
else relation.dataset_id
|
||||
)
|
||||
assert resource_id is not None
|
||||
return ResourceAccessTokenRow(
|
||||
token_id=token.id,
|
||||
relation_id=relation.id,
|
||||
name=token.name,
|
||||
track_id=token.track_id,
|
||||
token=token.token if include_token else None,
|
||||
masked_token=cls.mask_token(token.token),
|
||||
resource_type=relation.resource_type,
|
||||
resource_id=resource_id,
|
||||
resource_name=app_name or dataset_name or "",
|
||||
created_at=token.created_at,
|
||||
last_used_at=token.last_used_at,
|
||||
)
|
||||
@@ -52,6 +52,7 @@ from models.workflow import (
|
||||
)
|
||||
from repositories.factory import DifyAPIRepositoryFactory
|
||||
from services.api_token_service import ApiTokenCache
|
||||
from services.resource_access_token_service import ResourceAccessTokenService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -66,6 +67,7 @@ def remove_app_and_related_data_task(self, tenant_id: str, app_id: str):
|
||||
_delete_app_site(tenant_id, app_id)
|
||||
_delete_app_mcp_servers(tenant_id, app_id)
|
||||
_delete_app_api_tokens(tenant_id, app_id)
|
||||
_delete_app_resource_access_token_relations(app_id)
|
||||
_delete_installed_apps(tenant_id, app_id)
|
||||
_delete_app_stars(tenant_id, app_id)
|
||||
_delete_recommended_apps(tenant_id, app_id)
|
||||
@@ -164,6 +166,12 @@ def _delete_app_api_tokens(tenant_id: str, app_id: str):
|
||||
)
|
||||
|
||||
|
||||
def _delete_app_resource_access_token_relations(app_id: str):
|
||||
with session_factory.get_session_maker()() as session:
|
||||
ResourceAccessTokenService.delete_relations_for_app(app_id=app_id, session=session)
|
||||
session.commit()
|
||||
|
||||
|
||||
def _delete_installed_apps(tenant_id: str, app_id: str):
|
||||
def del_installed_app(session: Session, installed_app_id: str):
|
||||
session.execute(
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
from controllers.console.resource_access_token import _require_owner
|
||||
from models.account import TenantAccountRole
|
||||
|
||||
|
||||
def test_require_owner_allows_owner():
|
||||
_require_owner(SimpleNamespace(current_role=TenantAccountRole.OWNER))
|
||||
|
||||
|
||||
def test_require_owner_rejects_non_owner():
|
||||
with pytest.raises(Forbidden):
|
||||
_require_owner(SimpleNamespace(current_role=TenantAccountRole.ADMIN))
|
||||
@@ -0,0 +1,347 @@
|
||||
from contextlib import nullcontext
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden, Unauthorized
|
||||
|
||||
from controllers.openapi.auth.requirements import CheckScope, CheckSubject
|
||||
from controllers.openapi.auth.resource_access import authenticate_resource_token
|
||||
from controllers.openapi.auth.router import subject_router
|
||||
from controllers.openapi.auth.spec import EndpointSpec
|
||||
from controllers.openapi.auth.subjects import AccountSubject, ResourceAccessSubject
|
||||
from libs.oauth_bearer import Scope, TokenType
|
||||
from models.account import Tenant
|
||||
from models.enums import CreatorUserRole
|
||||
from models.model import App, AppMode, AppStar, EndUser
|
||||
from models.resource_access_token import (
|
||||
ResourceAccessToken,
|
||||
ResourceAccessTokenRelation,
|
||||
ResourceAccessTokenResourceType,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.parametrize(
|
||||
"sqlite_session",
|
||||
[(Tenant, App, AppStar, ResourceAccessToken, ResourceAccessTokenRelation)],
|
||||
indirect=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def resource_fixture(sqlite_session: Session):
|
||||
tenant = Tenant(name="Token workspace")
|
||||
tenant.id = str(uuid4())
|
||||
app = App(
|
||||
id=str(uuid4()), tenant_id=tenant.id, name="Bound app", mode=AppMode.CHAT, enable_api=True, enable_site=False
|
||||
)
|
||||
token = ResourceAccessToken(
|
||||
id=str(uuid4()), tenant_id=tenant.id, name="CLI", token="sk-test", track_id="test", created_by=str(uuid4())
|
||||
)
|
||||
relation = ResourceAccessTokenRelation(
|
||||
token_id=token.id, resource_type=ResourceAccessTokenResourceType.APP, app_id=app.id
|
||||
)
|
||||
sqlite_session.add_all([tenant, app, token, relation])
|
||||
sqlite_session.commit()
|
||||
flask_app = Flask(__name__)
|
||||
flask_app.login_manager = MagicMock()
|
||||
with (
|
||||
patch(
|
||||
"controllers.openapi.auth.resource_access.session_factory.create_session",
|
||||
side_effect=lambda: nullcontext(sqlite_session),
|
||||
),
|
||||
patch("controllers.openapi.auth.resource_access.enforce_bearer_rate_limit"),
|
||||
patch(
|
||||
"controllers.openapi.auth.subjects.EndUserService.get_or_create_end_user_by_type",
|
||||
return_value=MagicMock(spec=EndUser),
|
||||
) as end_user,
|
||||
patch("controllers.openapi.auth.pipelines._mount_flask_login"),
|
||||
patch("controllers.openapi.auth.router.assert_license_valid"),
|
||||
):
|
||||
yield flask_app, app.id, token.id, tenant.id, end_user
|
||||
|
||||
|
||||
def call(flask_app, app_id=None, workspace_id=None, scope=Scope.APPS_RUN, allowed=None):
|
||||
path = "/apps" + (f"/{app_id}" if app_id else "")
|
||||
if workspace_id:
|
||||
path += f"?workspace_id={workspace_id}"
|
||||
with flask_app.test_request_context(path, headers={"Authorization": "Bearer sk-test"}):
|
||||
from flask import request
|
||||
|
||||
request.view_args = {"app_id": app_id} if app_id else {}
|
||||
|
||||
@subject_router.guard(
|
||||
EndpointSpec(
|
||||
requirements=(
|
||||
CheckSubject(allowed=(AccountSubject,) if allowed else (ResourceAccessSubject,)),
|
||||
CheckScope(scope),
|
||||
)
|
||||
)
|
||||
)
|
||||
def view(*, ctx):
|
||||
return ctx
|
||||
|
||||
return view()
|
||||
|
||||
|
||||
def test_bound_app_uses_machine_end_user_without_account(resource_fixture):
|
||||
flask_app, app_id, token_id, tenant_id, end_user = resource_fixture
|
||||
data = call(flask_app, app_id)
|
||||
assert data.subject.caller_role == CreatorUserRole.END_USER
|
||||
assert data.subject.account_id is None
|
||||
assert data.app.id == app_id
|
||||
end_user.assert_called_once()
|
||||
assert end_user.call_args.kwargs["user_id"] == f"resource-token:{token_id}"
|
||||
assert end_user.call_args.kwargs["tenant_id"] == tenant_id
|
||||
|
||||
|
||||
def test_rejects_unbound_app_before_creating_caller(resource_fixture):
|
||||
flask_app, _, _, _, end_user = resource_fixture
|
||||
with pytest.raises(Forbidden, match="resource_not_authorized"):
|
||||
call(flask_app, str(uuid4()))
|
||||
end_user.assert_not_called()
|
||||
|
||||
|
||||
def test_rejects_other_workspace(resource_fixture):
|
||||
flask_app, _, _, _, _ = resource_fixture
|
||||
with pytest.raises(Forbidden, match="resource_not_authorized"):
|
||||
call(flask_app, workspace_id=str(uuid4()), scope=Scope.APPS_READ)
|
||||
|
||||
|
||||
def test_binding_revocation_is_immediate(resource_fixture, sqlite_session):
|
||||
flask_app, app_id, token_id, _, _ = resource_fixture
|
||||
call(flask_app, app_id)
|
||||
sqlite_session.execute(delete(ResourceAccessTokenRelation).where(ResourceAccessTokenRelation.token_id == token_id))
|
||||
sqlite_session.commit()
|
||||
with pytest.raises(Forbidden):
|
||||
call(flask_app, app_id)
|
||||
|
||||
|
||||
def test_token_revocation_is_immediate(resource_fixture, sqlite_session):
|
||||
flask_app, app_id, token_id, _, _ = resource_fixture
|
||||
call(flask_app, app_id)
|
||||
sqlite_session.execute(delete(ResourceAccessToken).where(ResourceAccessToken.id == token_id))
|
||||
sqlite_session.commit()
|
||||
with pytest.raises(Unauthorized):
|
||||
call(flask_app, app_id)
|
||||
|
||||
|
||||
def test_disabled_app_is_rejected(resource_fixture, sqlite_session):
|
||||
flask_app, app_id, _, _, _ = resource_fixture
|
||||
sqlite_session.get(App, app_id).enable_api = False
|
||||
sqlite_session.commit()
|
||||
with pytest.raises(Forbidden):
|
||||
call(flask_app, app_id)
|
||||
|
||||
|
||||
def test_app_discovery_keeps_explicit_empty_binding_set(resource_fixture, sqlite_session):
|
||||
flask_app, _, token_id, tenant_id, _ = resource_fixture
|
||||
sqlite_session.execute(delete(ResourceAccessTokenRelation).where(ResourceAccessTokenRelation.token_id == token_id))
|
||||
sqlite_session.commit()
|
||||
data = call(flask_app, workspace_id=tenant_id, scope=Scope.APPS_READ)
|
||||
assert data.resource_app_ids == frozenset()
|
||||
|
||||
|
||||
def test_account_only_routes_reject_resource_token(resource_fixture):
|
||||
flask_app, _, _, _, _ = resource_fixture
|
||||
with pytest.raises(Forbidden, match="unsupported_token_type"):
|
||||
call(flask_app, scope=Scope.WORKSPACE_WRITE, allowed=frozenset({TokenType.OAUTH_ACCOUNT}))
|
||||
|
||||
|
||||
def test_invalid_token_is_unauthorized(resource_fixture):
|
||||
flask_app, _, _, _, _ = resource_fixture
|
||||
with flask_app.test_request_context("/"):
|
||||
with pytest.raises(Unauthorized):
|
||||
authenticate_resource_token("sk-invalid")
|
||||
|
||||
|
||||
def test_archived_workspace_is_rejected(resource_fixture, sqlite_session):
|
||||
flask_app, app_id, _, tenant_id, _ = resource_fixture
|
||||
sqlite_session.get(Tenant, tenant_id).status = "archive"
|
||||
sqlite_session.commit()
|
||||
with pytest.raises(Forbidden):
|
||||
call(flask_app, app_id)
|
||||
|
||||
|
||||
def test_resource_token_has_workspace_read_scope(resource_fixture):
|
||||
flask_app, _, _, tenant_id, _ = resource_fixture
|
||||
with flask_app.test_request_context("/"):
|
||||
identity = authenticate_resource_token("sk-test")
|
||||
assert identity.scopes == frozenset({Scope.WORKSPACE_READ, Scope.APPS_READ, Scope.APPS_RUN})
|
||||
assert str(call(flask_app, scope=Scope.WORKSPACE_READ).workspace.id) == tenant_id
|
||||
|
||||
|
||||
def test_workspace_list_returns_only_token_tenant(resource_fixture, sqlite_session):
|
||||
from controllers.openapi.workspaces import WorkspacesApi
|
||||
|
||||
flask_app, _, _, tenant_id, _ = resource_fixture
|
||||
other_tenant = Tenant(name="Other workspace")
|
||||
other_tenant.id = str(uuid4())
|
||||
sqlite_session.add(other_tenant)
|
||||
sqlite_session.commit()
|
||||
with flask_app.test_request_context("/workspaces", headers={"Authorization": "Bearer sk-test"}):
|
||||
result, status = WorkspacesApi().get()
|
||||
assert status == 200
|
||||
assert len(result["workspaces"]) == 1
|
||||
assert result["workspaces"][0] == {
|
||||
"id": tenant_id,
|
||||
"name": "Token workspace",
|
||||
"role": "",
|
||||
"status": "normal",
|
||||
"current": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("endpoint", ["detail", "members", "switch"])
|
||||
def test_other_workspace_endpoints_reject_resource_token(resource_fixture, endpoint):
|
||||
from controllers.openapi.workspaces import WorkspaceByIdApi, WorkspaceMembersApi, WorkspaceSwitchApi
|
||||
|
||||
flask_app, _, _, tenant_id, _ = resource_fixture
|
||||
methods = {
|
||||
"detail": WorkspaceByIdApi().get,
|
||||
"members": WorkspaceMembersApi().get,
|
||||
"switch": WorkspaceSwitchApi().post,
|
||||
}
|
||||
with flask_app.test_request_context("/workspaces", headers={"Authorization": "Bearer sk-test"}):
|
||||
with pytest.raises(Forbidden, match="unsupported_token_type"):
|
||||
methods[endpoint](workspace_id=tenant_id)
|
||||
|
||||
|
||||
def test_stop_task_rejects_other_token_owner(resource_fixture):
|
||||
import inspect
|
||||
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from controllers.openapi.app_run import AppRunTaskStopApi
|
||||
|
||||
flask_app, app_id, _, _, end_user = resource_fixture
|
||||
data = call(flask_app, app_id)
|
||||
end_user.return_value.id = "machine-user"
|
||||
with (
|
||||
patch("controllers.openapi.app_run.redis_client") as redis,
|
||||
patch("controllers.openapi.app_run.AppQueueManager.set_stop_flag_no_user_check") as stop,
|
||||
patch("controllers.openapi.app_run.GraphEngineManager") as engine,
|
||||
):
|
||||
redis.get.return_value = b"end-user-other-user"
|
||||
with pytest.raises(NotFound):
|
||||
inspect.unwrap(AppRunTaskStopApi.post)(AppRunTaskStopApi(), data, app_id, "task")
|
||||
stop.assert_not_called()
|
||||
engine.assert_not_called()
|
||||
redis.get.return_value = b"end-user-machine-user"
|
||||
inspect.unwrap(AppRunTaskStopApi.post)(AppRunTaskStopApi(), data, app_id, "task")
|
||||
stop.assert_called_once_with("task")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("search_unbound", [False, True])
|
||||
def test_app_list_filters_unbound_apps_before_pagination(resource_fixture, sqlite_session, search_unbound):
|
||||
import inspect
|
||||
|
||||
from controllers.openapi._models import AppListQuery
|
||||
from controllers.openapi.apps import AppListApi
|
||||
|
||||
flask_app, app_id, _, tenant_id, _ = resource_fixture
|
||||
unbound = App(
|
||||
id=str(uuid4()), tenant_id=tenant_id, name="Unbound app", mode=AppMode.CHAT, enable_api=True, enable_site=False
|
||||
)
|
||||
sqlite_session.add(unbound)
|
||||
sqlite_session.commit()
|
||||
with flask_app.test_request_context("/"):
|
||||
data = call(flask_app, workspace_id=tenant_id, scope=Scope.APPS_READ)
|
||||
result = inspect.unwrap(AppListApi.get)(
|
||||
AppListApi(),
|
||||
data,
|
||||
query=AppListQuery(workspace_id=tenant_id, name=unbound.id if search_unbound else None),
|
||||
)
|
||||
assert result.total == (0 if search_unbound else 1)
|
||||
assert [app.id for app in result.data] == ([] if search_unbound else [app_id])
|
||||
|
||||
|
||||
def test_resource_token_cannot_gain_workspace_write_scope(resource_fixture):
|
||||
flask_app, _, _, _, end_user = resource_fixture
|
||||
with pytest.raises(Forbidden, match="insufficient_scope"):
|
||||
call(flask_app, scope=Scope.WORKSPACE_WRITE)
|
||||
end_user.assert_not_called()
|
||||
|
||||
|
||||
def test_cross_tenant_binding_does_not_authorize_app(resource_fixture, sqlite_session):
|
||||
flask_app, app_id, _, _, end_user = resource_fixture
|
||||
other_tenant = Tenant(name="Other workspace")
|
||||
other_tenant.id = str(uuid4())
|
||||
sqlite_session.add(other_tenant)
|
||||
sqlite_session.get(App, app_id).tenant_id = other_tenant.id
|
||||
sqlite_session.commit()
|
||||
with pytest.raises(Forbidden, match="resource_not_authorized"):
|
||||
call(flask_app, app_id)
|
||||
end_user.assert_not_called()
|
||||
|
||||
|
||||
def test_bound_app_describe_uses_new_endpoint_contract(resource_fixture):
|
||||
from flask import request
|
||||
|
||||
from controllers.openapi.apps import AppDescribeApi
|
||||
|
||||
flask_app, app_id, _, _, _ = resource_fixture
|
||||
with flask_app.test_request_context(f"/apps/{app_id}?fields=info", headers={"Authorization": "Bearer sk-test"}):
|
||||
request.view_args = {"app_id": app_id}
|
||||
result, status = AppDescribeApi().get(app_id=app_id)
|
||||
assert status == 200
|
||||
assert result["info"]["id"] == app_id
|
||||
|
||||
|
||||
def test_successful_request_records_token_usage(resource_fixture, sqlite_session):
|
||||
flask_app, _, token_id, _, _ = resource_fixture
|
||||
assert sqlite_session.get(ResourceAccessToken, token_id).last_used_at is None
|
||||
call(flask_app, scope=Scope.WORKSPACE_READ)
|
||||
assert sqlite_session.get(ResourceAccessToken, token_id).last_used_at is not None
|
||||
|
||||
|
||||
def test_resource_token_works_when_oauth_is_disabled(resource_fixture, config_overrides):
|
||||
config_overrides(ENABLE_OAUTH_BEARER=False)
|
||||
flask_app, app_id, _, _, _ = resource_fixture
|
||||
with patch("controllers.openapi.auth.router.get_authenticator") as oauth:
|
||||
ctx = call(flask_app, app_id)
|
||||
assert ctx.app.id == app_id
|
||||
oauth.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route", ["upload", "events", "form_get", "form_submit"])
|
||||
@pytest.mark.parametrize("bound", [True, False], ids=["bound", "unbound"])
|
||||
def test_run_companion_routes_enforce_resource_bindings(resource_fixture, route, bound):
|
||||
from flask import request
|
||||
|
||||
from controllers.openapi.files import AppFileUploadApi
|
||||
from controllers.openapi.human_input_form import (
|
||||
OpenApiWorkflowHumanInputFormApi,
|
||||
OpenApiWorkflowHumanInputFormSubmitApi,
|
||||
)
|
||||
from controllers.openapi.workflow_events import OpenApiWorkflowEventsApi
|
||||
|
||||
handlers = {
|
||||
"upload": AppFileUploadApi.post,
|
||||
"events": OpenApiWorkflowEventsApi.get,
|
||||
"form_get": OpenApiWorkflowHumanInputFormApi.get,
|
||||
"form_submit": OpenApiWorkflowHumanInputFormSubmitApi.post,
|
||||
}
|
||||
flask_app, app_id, _, _, end_user = resource_fixture
|
||||
handler = MagicMock(return_value="admitted")
|
||||
# Exercise the production endpoint's entire authorization pipeline without
|
||||
# invoking file storage, workflow streaming, or form submission side effects.
|
||||
guarded = subject_router.guard(handlers[route].__spec__)(handler)
|
||||
with (
|
||||
flask_app.test_request_context("/apps", headers={"Authorization": "Bearer sk-test"}),
|
||||
patch("controllers.openapi.human_input_form.HumanInputService") as forms,
|
||||
):
|
||||
forms.return_value.get_form_by_token.return_value = None
|
||||
request.view_args = {"app_id": app_id if bound else str(uuid4()), "form_token": "form"}
|
||||
if bound:
|
||||
assert guarded() == "admitted"
|
||||
ctx = handler.call_args.kwargs["ctx"]
|
||||
assert ctx.app.id == app_id
|
||||
assert ctx.end_user is end_user.return_value
|
||||
else:
|
||||
with pytest.raises(Forbidden, match="resource_not_authorized"):
|
||||
guarded()
|
||||
handler.assert_not_called()
|
||||
end_user.assert_not_called()
|
||||
@@ -101,7 +101,9 @@ def test_a_dead_licence_403s_an_unauthenticated_caller_on_every_route_in_enterpr
|
||||
view()
|
||||
|
||||
|
||||
def test_a_disabled_bearer_feature_503s_before_the_bearer_is_read(
|
||||
@pytest.mark.parametrize("token", [None, "dfoa_test", "dfoe_test", "unknown"])
|
||||
def test_a_disabled_oauth_feature_503s_before_authentication(
|
||||
token: str | None,
|
||||
app: Flask,
|
||||
config_overrides: Callable[..., None],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -110,10 +112,11 @@ def test_a_disabled_bearer_feature_503s_before_the_bearer_is_read(
|
||||
the device endpoints do instead of a 500 from the unbound authenticator.
|
||||
"""
|
||||
config_overrides(ENABLE_OAUTH_BEARER=False)
|
||||
monkeypatch.setattr(f"{ROUTER}.extract_bearer", never_reached)
|
||||
monkeypatch.setattr(f"{ROUTER}.get_authenticator", never_reached)
|
||||
view = _guard(_nothing)
|
||||
|
||||
with app.test_request_context("/openapi/v1/account"):
|
||||
headers = {"Authorization": f"Bearer {token}"} if token else {}
|
||||
with app.test_request_context("/openapi/v1/account", headers=headers):
|
||||
with pytest.raises(ServiceUnavailable, match="bearer_auth_disabled"):
|
||||
view()
|
||||
|
||||
|
||||
@@ -58,7 +58,9 @@ def test_stop_task_calls_queue_manager_and_graph_engine(app: Flask, monkeypatch:
|
||||
|
||||
api = AppRunTaskStopApi()
|
||||
with app.test_request_context("/openapi/v1/apps/app-1/tasks/task-1:stop", method="POST"):
|
||||
result = api.post.__handler__(api, _SealableContext(), app_id="app-1", task_id="task-1")
|
||||
result = api.post.__handler__(
|
||||
api, _SealableContext(subject=SimpleNamespace()), app_id="app-1", task_id="task-1"
|
||||
)
|
||||
|
||||
queue_mock.set_stop_flag_no_user_check.assert_called_once_with("task-1")
|
||||
graph_instance.send_stop_command.assert_called_once_with("task-1")
|
||||
|
||||
@@ -88,7 +88,7 @@ from controllers.openapi.auth.requirements import (
|
||||
Requirement,
|
||||
)
|
||||
from controllers.openapi.auth.spec import EndpointSpec
|
||||
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject
|
||||
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject, ResourceAccessSubject
|
||||
from controllers.openapi.human_input_form import CheckFormSurface
|
||||
from enums import DeploymentEdition, WebAppAccessMode
|
||||
from libs.oauth_bearer import (
|
||||
@@ -741,9 +741,12 @@ DECLARED: dict[str, tuple[Requirement, ...]] = {
|
||||
"account.sessions.revoke_self": _REQ_ACCOUNT_FULL,
|
||||
"account.sessions.list": _REQ_ACCOUNT_FULL,
|
||||
"account.sessions.revoke_one": _REQ_ACCOUNT_FULL,
|
||||
"apps.describe": _REQ_APP_DESCRIBE,
|
||||
"apps.list": _REQ_ACCOUNT_APPS_READ_MEMBER,
|
||||
"workspaces.list": _REQ_ACCOUNT_WORKSPACE_READ,
|
||||
"apps.describe": (CheckSubject(allowed=(AccountSubject, ResourceAccessSubject)), *_REQ_APP_DESCRIBE[1:]),
|
||||
"apps.list": (CheckSubject(allowed=(AccountSubject, ResourceAccessSubject)), *_REQ_ACCOUNT_APPS_READ_MEMBER[1:]),
|
||||
"workspaces.list": (
|
||||
CheckSubject(allowed=(AccountSubject, ResourceAccessSubject)),
|
||||
*_REQ_ACCOUNT_WORKSPACE_READ[1:],
|
||||
),
|
||||
"workspaces.describe": _REQ_ACCOUNT_WORKSPACE_READ,
|
||||
"workspaces.switch": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER,
|
||||
"workspaces.members.list": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER,
|
||||
@@ -754,12 +757,24 @@ DECLARED: dict[str, tuple[Requirement, ...]] = {
|
||||
"app_dsl.import_confirm": _REQ_DSL_WORKSPACE,
|
||||
"app_dsl.export": _REQ_DSL_APP,
|
||||
"app_dsl.check_dependencies": _REQ_DSL_APP,
|
||||
"app_run.run": _REQ_RUN,
|
||||
"app_run.stop": _REQ_RUN,
|
||||
"files.upload": _REQ_FILES,
|
||||
"human_input_form.get": _REQ_RUN_FORM,
|
||||
"human_input_form.submit": _REQ_RUN_FORM,
|
||||
"workflow_events.stream": _REQ_RUN,
|
||||
"app_run.run": (CheckSubject(allowed=(AccountSubject, ExternalSsoSubject, ResourceAccessSubject)), *_REQ_RUN[1:]),
|
||||
"app_run.stop": (CheckSubject(allowed=(AccountSubject, ExternalSsoSubject, ResourceAccessSubject)), *_REQ_RUN[1:]),
|
||||
"files.upload": (
|
||||
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject, ResourceAccessSubject)),
|
||||
*_REQ_FILES[1:],
|
||||
),
|
||||
"human_input_form.get": (
|
||||
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject, ResourceAccessSubject)),
|
||||
*_REQ_RUN_FORM[1:],
|
||||
),
|
||||
"human_input_form.submit": (
|
||||
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject, ResourceAccessSubject)),
|
||||
*_REQ_RUN_FORM[1:],
|
||||
),
|
||||
"workflow_events.stream": (
|
||||
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject, ResourceAccessSubject)),
|
||||
*_REQ_RUN[1:],
|
||||
),
|
||||
"permitted_external.list": _REQ_EXTERNAL_LIST,
|
||||
"permitted_external.describe": _REQ_EXTERNAL_DESCRIBE,
|
||||
}
|
||||
|
||||
@@ -30,6 +30,11 @@ from models.account import TenantAccountRole
|
||||
from models.dataset import Dataset, RateLimitLog
|
||||
from models.enums import ApiTokenType
|
||||
from models.model import ApiToken, App, AppMode, DatasetApiTokenBinding, IconType
|
||||
from models.resource_access_token import (
|
||||
ResourceAccessToken,
|
||||
ResourceAccessTokenRelation,
|
||||
ResourceAccessTokenResourceType,
|
||||
)
|
||||
from tests.unit_tests.config_override import config_overrides_context
|
||||
|
||||
|
||||
@@ -223,6 +228,59 @@ class TestValidateAppToken:
|
||||
assert result["app_id"] == app_model.id
|
||||
assert account.current_tenant_id == tenant.id
|
||||
|
||||
@patch("controllers.service_api.wraps.user_logged_in")
|
||||
@patch("controllers.service_api.wraps.current_app")
|
||||
@pytest.mark.parametrize(
|
||||
"sqlite_session",
|
||||
[(App, ResourceAccessToken, ResourceAccessTokenRelation, Tenant, Account, TenantAccountJoin)],
|
||||
indirect=True,
|
||||
)
|
||||
def test_resource_access_token_allows_bound_app(
|
||||
self,
|
||||
mock_current_app,
|
||||
mock_user_logged_in,
|
||||
app: Flask,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
_configure_current_app_mock(mock_current_app)
|
||||
tenant, account, _ = _persist_workspace(sqlite_session)
|
||||
app_model = _app_model(tenant_id=tenant.id)
|
||||
access_token = ResourceAccessToken(
|
||||
tenant_id=tenant.id,
|
||||
name="Production integration",
|
||||
track_id="a" * 32,
|
||||
token="sk-00000000-0000-0000-0000-000000000000",
|
||||
created_by=account.id,
|
||||
)
|
||||
sqlite_session.add_all([app_model, access_token])
|
||||
sqlite_session.flush()
|
||||
sqlite_session.add(
|
||||
ResourceAccessTokenRelation(
|
||||
token_id=access_token.id,
|
||||
resource_type=ResourceAccessTokenResourceType.APP,
|
||||
app_id=app_model.id,
|
||||
dataset_id=None,
|
||||
)
|
||||
)
|
||||
sqlite_session.commit()
|
||||
|
||||
@validate_app_token
|
||||
def protected_view(app_model):
|
||||
return {"success": True, "app_id": app_model.id}
|
||||
|
||||
with (
|
||||
app.test_request_context(
|
||||
"/",
|
||||
method="GET",
|
||||
headers={"Authorization": f"Bearer {access_token.token}"},
|
||||
),
|
||||
patch("controllers.service_api.wraps.db.session", _session_proxy(sqlite_session)),
|
||||
):
|
||||
result = protected_view()
|
||||
|
||||
assert result["app_id"] == app_model.id
|
||||
assert sqlite_session.get(ResourceAccessToken, access_token.id).last_used_at is not None
|
||||
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
|
||||
def test_app_not_found_raises_forbidden(self, mock_validate_token, app: Flask, sqlite_session: Session):
|
||||
@@ -695,6 +753,62 @@ class TestValidateDatasetToken:
|
||||
assert result["tenant_id"] == tenant.id
|
||||
assert account.current_tenant_id == tenant.id
|
||||
|
||||
@patch("controllers.service_api.wraps.user_logged_in")
|
||||
@patch("controllers.service_api.wraps.current_app")
|
||||
@pytest.mark.parametrize(
|
||||
"sqlite_session",
|
||||
[(Dataset, ResourceAccessToken, ResourceAccessTokenRelation, Tenant, Account, TenantAccountJoin)],
|
||||
indirect=True,
|
||||
)
|
||||
def test_resource_access_token_allows_bound_dataset(
|
||||
self,
|
||||
mock_current_app,
|
||||
mock_user_logged_in,
|
||||
app: Flask,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
_configure_current_app_mock(mock_current_app)
|
||||
tenant, account, _ = _persist_workspace(sqlite_session)
|
||||
dataset = Dataset(
|
||||
id=str(uuid.uuid4()),
|
||||
tenant_id=tenant.id,
|
||||
name="Customer Support",
|
||||
created_by=account.id,
|
||||
enable_api=True,
|
||||
)
|
||||
access_token = ResourceAccessToken(
|
||||
tenant_id=tenant.id,
|
||||
name="Production integration",
|
||||
track_id="b" * 32,
|
||||
token="sk-00000000-0000-0000-0000-000000000001",
|
||||
created_by=account.id,
|
||||
)
|
||||
sqlite_session.add_all([dataset, access_token])
|
||||
sqlite_session.flush()
|
||||
sqlite_session.add(
|
||||
ResourceAccessTokenRelation(
|
||||
token_id=access_token.id,
|
||||
resource_type=ResourceAccessTokenResourceType.KNOWLEDGE,
|
||||
app_id=None,
|
||||
dataset_id=dataset.id,
|
||||
)
|
||||
)
|
||||
sqlite_session.commit()
|
||||
|
||||
@validate_dataset_token
|
||||
def protected_view(tenant_id, dataset_id=None):
|
||||
return {"success": True, "tenant_id": tenant_id, "dataset_id": dataset_id}
|
||||
|
||||
with (
|
||||
app.test_request_context("/", method="GET", headers={"Authorization": f"Bearer {access_token.token}"}),
|
||||
patch("controllers.service_api.wraps.db.session", _session_proxy(sqlite_session)),
|
||||
):
|
||||
result = protected_view(dataset_id=dataset.id)
|
||||
|
||||
assert result["tenant_id"] == tenant.id
|
||||
assert result["dataset_id"] == dataset.id
|
||||
assert sqlite_session.get(ResourceAccessToken, access_token.id).last_used_at is not None
|
||||
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
@pytest.mark.parametrize("sqlite_session", [(Dataset,)], indirect=True)
|
||||
def test_dataset_not_found_raises_not_found(self, mock_validate_token, app: Flask, sqlite_session: Session):
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
|
||||
from models.dataset import Dataset
|
||||
from models.model import App, AppMode, IconType
|
||||
from models.resource_access_token import (
|
||||
ResourceAccessToken,
|
||||
ResourceAccessTokenRelation,
|
||||
ResourceAccessTokenResourceType,
|
||||
)
|
||||
from services.resource_access_token_service import (
|
||||
ResourceAccessTokenResource,
|
||||
ResourceAccessTokenService,
|
||||
)
|
||||
|
||||
|
||||
def _workspace(session: Session) -> tuple[Tenant, Account]:
|
||||
tenant = Tenant(name="Workspace")
|
||||
tenant.id = str(uuid4())
|
||||
owner = Account(name="Owner", email=f"owner-{tenant.id}@example.com")
|
||||
owner.id = str(uuid4())
|
||||
session.add_all(
|
||||
[
|
||||
tenant,
|
||||
owner,
|
||||
TenantAccountJoin(tenant_id=tenant.id, account_id=owner.id, role=TenantAccountRole.OWNER),
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
return tenant, owner
|
||||
|
||||
|
||||
def _app(session: Session, tenant_id: str) -> App:
|
||||
app = App(
|
||||
id=str(uuid4()),
|
||||
tenant_id=tenant_id,
|
||||
name="Customer FAQ Bot",
|
||||
mode=AppMode.CHAT,
|
||||
icon_type=IconType.EMOJI,
|
||||
icon="🤖",
|
||||
icon_background="#ffffff",
|
||||
enable_site=False,
|
||||
enable_api=True,
|
||||
)
|
||||
session.add(app)
|
||||
session.commit()
|
||||
return app
|
||||
|
||||
|
||||
def _dataset(session: Session, tenant_id: str, created_by: str) -> Dataset:
|
||||
dataset = Dataset(
|
||||
id=str(uuid4()),
|
||||
tenant_id=tenant_id,
|
||||
name="Customer Support",
|
||||
created_by=created_by,
|
||||
enable_api=True,
|
||||
)
|
||||
session.add(dataset)
|
||||
session.commit()
|
||||
return dataset
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sqlite_session",
|
||||
[(Tenant, Account, TenantAccountJoin, App, Dataset, ResourceAccessToken, ResourceAccessTokenRelation)],
|
||||
indirect=True,
|
||||
)
|
||||
def test_create_token_with_mixed_resources_returns_expanded_rows(sqlite_session: Session) -> None:
|
||||
tenant, owner = _workspace(sqlite_session)
|
||||
app = _app(sqlite_session, tenant.id)
|
||||
dataset = _dataset(sqlite_session, tenant.id, owner.id)
|
||||
|
||||
result = ResourceAccessTokenService.create(
|
||||
tenant_id=tenant.id,
|
||||
created_by=owner.id,
|
||||
name="Production integration",
|
||||
resources=[
|
||||
ResourceAccessTokenResource(type=ResourceAccessTokenResourceType.APP, id=app.id),
|
||||
ResourceAccessTokenResource(type=ResourceAccessTokenResourceType.KNOWLEDGE, id=dataset.id),
|
||||
],
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
assert result.token.startswith("sk-")
|
||||
assert len(result.rows) == 2
|
||||
assert {row.resource_type for row in result.rows} == {
|
||||
ResourceAccessTokenResourceType.APP,
|
||||
ResourceAccessTokenResourceType.KNOWLEDGE,
|
||||
}
|
||||
assert {row.resource_id for row in result.rows} == {app.id, dataset.id}
|
||||
assert {row.name for row in result.rows} == {"Production integration"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sqlite_session",
|
||||
[(Tenant, Account, TenantAccountJoin, App, Dataset, ResourceAccessToken, ResourceAccessTokenRelation)],
|
||||
indirect=True,
|
||||
)
|
||||
def test_list_paginates_and_counts_tokens_not_relations(sqlite_session: Session) -> None:
|
||||
tenant, owner = _workspace(sqlite_session)
|
||||
first_app = _app(sqlite_session, tenant.id)
|
||||
second_app = _app(sqlite_session, tenant.id)
|
||||
dataset = _dataset(sqlite_session, tenant.id, owner.id)
|
||||
ResourceAccessTokenService.create(
|
||||
tenant_id=tenant.id,
|
||||
created_by=owner.id,
|
||||
name="First integration",
|
||||
resources=[ResourceAccessTokenResource(type=ResourceAccessTokenResourceType.APP, id=first_app.id)],
|
||||
session=sqlite_session,
|
||||
)
|
||||
newest = ResourceAccessTokenService.create(
|
||||
tenant_id=tenant.id,
|
||||
created_by=owner.id,
|
||||
name="Newest integration",
|
||||
resources=[
|
||||
ResourceAccessTokenResource(type=ResourceAccessTokenResourceType.APP, id=second_app.id),
|
||||
ResourceAccessTokenResource(type=ResourceAccessTokenResourceType.KNOWLEDGE, id=dataset.id),
|
||||
],
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
rows = ResourceAccessTokenService.list_rows(tenant_id=tenant.id, page=1, limit=1, session=sqlite_session)
|
||||
|
||||
assert ResourceAccessTokenService.count_rows(tenant_id=tenant.id, session=sqlite_session) == 2
|
||||
assert {row.token_id for row in rows} == {newest.token_id}
|
||||
assert len(rows) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sqlite_session",
|
||||
[(Tenant, Account, TenantAccountJoin, App, ResourceAccessToken, ResourceAccessTokenRelation)],
|
||||
indirect=True,
|
||||
)
|
||||
def test_update_changes_all_expanded_rows(sqlite_session: Session) -> None:
|
||||
tenant, owner = _workspace(sqlite_session)
|
||||
first_app = _app(sqlite_session, tenant.id)
|
||||
second_app = _app(sqlite_session, tenant.id)
|
||||
created = ResourceAccessTokenService.create(
|
||||
tenant_id=tenant.id,
|
||||
created_by=owner.id,
|
||||
name="Production integration",
|
||||
resources=[
|
||||
ResourceAccessTokenResource(type=ResourceAccessTokenResourceType.APP, id=first_app.id),
|
||||
ResourceAccessTokenResource(type=ResourceAccessTokenResourceType.APP, id=second_app.id),
|
||||
],
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
rows = ResourceAccessTokenService.update(
|
||||
tenant_id=tenant.id,
|
||||
token_id=created.token_id,
|
||||
name="Internal integration",
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
assert {row.name for row in rows} == {"Internal integration"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sqlite_session",
|
||||
[(Tenant, Account, TenantAccountJoin, App, Dataset, ResourceAccessToken, ResourceAccessTokenRelation)],
|
||||
indirect=True,
|
||||
)
|
||||
def test_update_replaces_resources(sqlite_session: Session) -> None:
|
||||
tenant, owner = _workspace(sqlite_session)
|
||||
first_app = _app(sqlite_session, tenant.id)
|
||||
second_app = _app(sqlite_session, tenant.id)
|
||||
dataset = _dataset(sqlite_session, tenant.id, owner.id)
|
||||
created = ResourceAccessTokenService.create(
|
||||
tenant_id=tenant.id,
|
||||
created_by=owner.id,
|
||||
name="Production integration",
|
||||
resources=[
|
||||
ResourceAccessTokenResource(type=ResourceAccessTokenResourceType.APP, id=first_app.id),
|
||||
ResourceAccessTokenResource(type=ResourceAccessTokenResourceType.KNOWLEDGE, id=dataset.id),
|
||||
],
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
rows = ResourceAccessTokenService.update(
|
||||
tenant_id=tenant.id,
|
||||
token_id=created.token_id,
|
||||
name="Internal integration",
|
||||
resources=[ResourceAccessTokenResource(type=ResourceAccessTokenResourceType.APP, id=second_app.id)],
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
assert {row.name for row in rows} == {"Internal integration"}
|
||||
assert [(row.resource_type, row.resource_id) for row in rows] == [
|
||||
(ResourceAccessTokenResourceType.APP, second_app.id)
|
||||
]
|
||||
assert sqlite_session.scalars(select(ResourceAccessTokenRelation)).one().app_id == second_app.id
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sqlite_session",
|
||||
[(Tenant, Account, TenantAccountJoin, App, Dataset, ResourceAccessToken, ResourceAccessTokenRelation)],
|
||||
indirect=True,
|
||||
)
|
||||
def test_delete_relation_removes_token_and_all_relations(sqlite_session: Session) -> None:
|
||||
tenant, owner = _workspace(sqlite_session)
|
||||
app = _app(sqlite_session, tenant.id)
|
||||
dataset = _dataset(sqlite_session, tenant.id, owner.id)
|
||||
created = ResourceAccessTokenService.create(
|
||||
tenant_id=tenant.id,
|
||||
created_by=owner.id,
|
||||
name="Production integration",
|
||||
resources=[
|
||||
ResourceAccessTokenResource(type=ResourceAccessTokenResourceType.APP, id=app.id),
|
||||
ResourceAccessTokenResource(type=ResourceAccessTokenResourceType.KNOWLEDGE, id=dataset.id),
|
||||
],
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
ResourceAccessTokenService.delete_relation(
|
||||
tenant_id=tenant.id,
|
||||
token_id=created.token_id,
|
||||
relation_id=created.rows[0].relation_id,
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
assert sqlite_session.scalar(select(ResourceAccessToken)) is None
|
||||
assert sqlite_session.scalar(select(ResourceAccessTokenRelation)) is None
|
||||
@@ -115,6 +115,17 @@ See [`ARD.md`] for architecture patterns, scaffolding recipe, dev workflow.
|
||||
## License
|
||||
|
||||
Apache-2.0.
|
||||
[`ARD.md`]: ARD.md
|
||||
|
||||
## Environment token authentication
|
||||
|
||||
`DIFY_TOKEN` takes precedence over saved login credentials and is not persisted.
|
||||
Use `DIFY_HOST` to select the server. Without `DIFY_TOKEN`, the normal saved-login
|
||||
flow is used.
|
||||
|
||||
Resource access tokens can list their own workspace through `/workspaces`.
|
||||
The CLI uses that workspace to query apps; only apps bound to the token are
|
||||
returned. Tokens do not inherit an account role or gain access to workspace
|
||||
details, members, or workspace switching.
|
||||
|
||||
[Dify]: https://dify.ai
|
||||
[`ARD.md`]: ARD.md
|
||||
|
||||
47
cli/src/auth/environment-token.ts
Normal file
47
cli/src/auth/environment-token.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { ActiveContext } from './hosts'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { AccountClient } from '@/api/account'
|
||||
import { WorkspacesClient } from '@/api/workspaces'
|
||||
import { getEnv } from '@/env/registry'
|
||||
import { createHttpClient } from '@/http/client'
|
||||
import { DEFAULT_HOST, openAPIBase, resolveHost } from '@/util/host'
|
||||
|
||||
/** Resolve a process-scoped credential without reading or saving a login session. */
|
||||
export async function environmentTokenContext(
|
||||
token: string,
|
||||
retryAttempts: number,
|
||||
): Promise<{ active: ActiveContext; http: HttpClient }> {
|
||||
const rawHost = getEnv('DIFY_HOST')?.trim() || DEFAULT_HOST
|
||||
// Local development over loopback needs no TLS bypass for remote hosts.
|
||||
const loopback = /^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?=[:/]|$)/.test(rawHost)
|
||||
const host = resolveHost({ raw: rawHost, insecure: loopback })
|
||||
const http = createHttpClient({ baseURL: openAPIBase(host), bearer: token, retryAttempts })
|
||||
if (token.startsWith('sk-')) {
|
||||
const { workspaces } = await new WorkspacesClient(http).list()
|
||||
return {
|
||||
http,
|
||||
active: {
|
||||
host,
|
||||
email: '',
|
||||
ctx: { account: { email: '', name: '' }, workspace: workspaces[0] },
|
||||
},
|
||||
}
|
||||
}
|
||||
const identity = await new AccountClient(http).get()
|
||||
const account = identity.account ?? { email: '', name: '' }
|
||||
return {
|
||||
http,
|
||||
active: {
|
||||
host,
|
||||
email: account.email,
|
||||
ctx: {
|
||||
account,
|
||||
workspace: identity.workspaces?.find((w) => w.id === identity.default_workspace_id),
|
||||
external_subject:
|
||||
identity.subject_type === 'external_sso'
|
||||
? { email: identity.subject_email ?? '', issuer: identity.subject_issuer ?? '' }
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
66
cli/src/commands/_shared/authed-command.test.ts
Normal file
66
cli/src/commands/_shared/authed-command.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { DifyMock } from '@test/fixtures/dify-mock/server'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { startMock } from '@test/fixtures/dify-mock/server'
|
||||
import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { AppsClient } from '@/api/apps'
|
||||
import { Registry } from '@/auth/hosts'
|
||||
import { buildAuthedContext } from './authed-command'
|
||||
|
||||
let server: DifyMock | undefined
|
||||
let dir: string | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs()
|
||||
await server?.stop()
|
||||
if (dir) await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function setup(token: string) {
|
||||
server = await startMock({ scenario: token.startsWith('sk-') ? 'resource-token' : 'happy' })
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-env-token-'))
|
||||
vi.stubEnv('DIFY_CONFIG_DIR', dir)
|
||||
vi.stubEnv('DIFY_HOST', server.url)
|
||||
vi.stubEnv('DIFY_TOKEN', token)
|
||||
}
|
||||
|
||||
const command = {
|
||||
error: (message: string): never => {
|
||||
throw new Error(message)
|
||||
},
|
||||
}
|
||||
|
||||
describe('DIFY_TOKEN authentication', () => {
|
||||
it('bootstraps the token workspace and lists only bound apps without a login', async () => {
|
||||
await setup('sk-test-resource')
|
||||
const ctx = await buildAuthedContext(command, { retryFlag: 0 })
|
||||
expect(ctx.active.ctx.workspace?.role).toBe('')
|
||||
const apps = await new AppsClient(ctx.http).list({ workspaceId: ctx.active.ctx.workspace!.id })
|
||||
expect(apps.data).toHaveLength(1)
|
||||
expect(apps.data[0]?.workspace_id).toBe(ctx.active.ctx.workspace!.id)
|
||||
expect((await Registry.load()).resolveActive()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses an OAuth environment token without a login', async () => {
|
||||
await setup('dfoa_test')
|
||||
const ctx = await buildAuthedContext(command, { retryFlag: 0 })
|
||||
expect(ctx.active.ctx.account.email).not.toBe('')
|
||||
expect((await Registry.load()).resolveActive()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports invalid tokens without falling back to login', async () => {
|
||||
await setup('sk-invalid')
|
||||
await expect(buildAuthedContext(command, { retryFlag: 0 })).rejects.toMatchObject({
|
||||
httpStatus: 401,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores broken saved login state when DIFY_TOKEN is provided', async () => {
|
||||
await setup('dfoa_test')
|
||||
const { writeFile } = await import('node:fs/promises')
|
||||
await writeFile(join(dir!, 'hosts.yml'), 'invalid: [')
|
||||
const ctx = await buildAuthedContext(command, { retryFlag: 0 })
|
||||
expect(ctx.active.ctx.account.email).not.toBe('')
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import type { HttpClient } from '@/http/types'
|
||||
import type { TokenStore } from '@/store/token-store'
|
||||
import type { IOStreams } from '@/sys/io/streams'
|
||||
import { META_PROBE_TIMEOUT_MS, MetaClient } from '@/api/meta'
|
||||
import { environmentTokenContext } from '@/auth/environment-token'
|
||||
import { notLoggedInError, Registry } from '@/auth/hosts'
|
||||
import { loadAppInfoCache } from '@/cache/app-info'
|
||||
import { loadNudgeStore } from '@/cache/nudge-store'
|
||||
@@ -40,19 +41,28 @@ export async function buildAuthedContext(
|
||||
opts: AuthedContextOptions,
|
||||
): Promise<AuthedContext> {
|
||||
const io = realStreams(opts.format ?? '')
|
||||
const reg = await Registry.load()
|
||||
const active = reg.resolveActive()
|
||||
if (active === undefined) fail(cmd, opts, io)
|
||||
|
||||
const envToken = getEnv('DIFY_TOKEN')?.trim()
|
||||
const reg = envToken ? Registry.empty() : await Registry.load()
|
||||
const store = getTokenStore(reg.token_storage)
|
||||
const bearer = await store.read(active.host, active.email)
|
||||
if (bearer === '') fail(cmd, opts, io)
|
||||
|
||||
const { host, insecure } = activeHostInfo(active)
|
||||
const retryAttempts = resolveRetryAttempts({ flag: opts.retryFlag, env: getEnv })
|
||||
const http = createHttpClient({ baseURL: openAPIBase(host), bearer, retryAttempts, insecure })
|
||||
|
||||
const cache = opts.withCache === true ? await loadAppInfoCache() : undefined
|
||||
let active: ActiveContext
|
||||
let http: HttpClient
|
||||
if (envToken) {
|
||||
const context = await environmentTokenContext(envToken, retryAttempts)
|
||||
active = context.active
|
||||
http = context.http
|
||||
} else {
|
||||
const stored = reg.resolveActive()
|
||||
if (stored === undefined) fail(cmd, opts, io)
|
||||
active = stored
|
||||
const bearer = await store.read(active.host, active.email)
|
||||
if (bearer === '') fail(cmd, opts, io)
|
||||
const { host, insecure } = activeHostInfo(active)
|
||||
http = createHttpClient({ baseURL: openAPIBase(host), bearer, retryAttempts, insecure })
|
||||
}
|
||||
const { host, insecure } = activeHostInfo(active)
|
||||
// Metadata cached for a logged-in account must not cross an environment-token boundary.
|
||||
const cache = opts.withCache === true && !envToken ? await loadAppInfoCache() : undefined
|
||||
|
||||
// Hard gate: refuse a server too old for this difyctl (throws → exit 6).
|
||||
// Cached per host (1h) so most commands don't re-probe. Then the soft nudge
|
||||
|
||||
1
cli/test/fixtures/dify-mock/scenarios.ts
vendored
1
cli/test/fixtures/dify-mock/scenarios.ts
vendored
@@ -1,5 +1,6 @@
|
||||
export type Scenario =
|
||||
| 'happy'
|
||||
| 'resource-token'
|
||||
| 'sso'
|
||||
| 'no-email'
|
||||
| 'denied'
|
||||
|
||||
25
cli/test/fixtures/dify-mock/server.ts
vendored
25
cli/test/fixtures/dify-mock/server.ts
vendored
@@ -142,7 +142,15 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
|
||||
return
|
||||
}
|
||||
const auth = c.req.header('Authorization') ?? ''
|
||||
if (!TOKEN_RE.test(auth)) return unauthorized()
|
||||
const resourceToken = getScenario() === 'resource-token' && auth === 'Bearer sk-test-resource'
|
||||
if (!TOKEN_RE.test(auth) && !resourceToken) return unauthorized()
|
||||
if (
|
||||
resourceToken &&
|
||||
c.req.path !== '/openapi/v1/workspaces' &&
|
||||
c.req.path !== '/openapi/v1/apps' &&
|
||||
!c.req.path.startsWith(`/openapi/v1/apps/${APPS[0]!.id}`)
|
||||
)
|
||||
return c.json({ code: 'forbidden', message: 'resource_not_authorized', status: 403 }, 403)
|
||||
const scenario = getScenario()
|
||||
if (scenario === 'auth-expired') return unauthorized()
|
||||
await next()
|
||||
@@ -224,6 +232,20 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
|
||||
|
||||
app.get('/openapi/v1/workspaces', (c) => {
|
||||
if (getScenario() === 'sso') return c.json({ workspaces: [] })
|
||||
if (getScenario() === 'resource-token') {
|
||||
const workspace = WORKSPACES.find((w) => w.id === APPS[0]!.workspace_id)!
|
||||
return c.json({
|
||||
workspaces: [
|
||||
{
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
role: '',
|
||||
status: workspace.status,
|
||||
current: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
return c.json({
|
||||
workspaces: WORKSPACES.map((w) => ({
|
||||
id: w.id,
|
||||
@@ -243,6 +265,7 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
|
||||
const name = c.req.query('name')
|
||||
const workspaceId = c.req.query('workspace_id') ?? ACCOUNT.current_workspace_id
|
||||
let filtered = APPS.filter((a) => a.workspace_id === workspaceId)
|
||||
if (getScenario() === 'resource-token') filtered = filtered.filter((a) => a.id === APPS[0]!.id)
|
||||
if (mode !== undefined && mode !== '') filtered = filtered.filter((a) => a.mode === mode)
|
||||
if (tag !== undefined && tag !== '')
|
||||
filtered = filtered.filter((a) => a.tags.some((t) => t.name === tag))
|
||||
|
||||
@@ -47,6 +47,12 @@ export const e2eWebEnvOverrides = {
|
||||
NEXT_PUBLIC_API_PREFIX: 'http://127.0.0.1:5001/console/api',
|
||||
NEXT_PUBLIC_ENABLE_AGENT_V2: 'true',
|
||||
NEXT_PUBLIC_PUBLIC_API_PREFIX: 'http://127.0.0.1:5001/api',
|
||||
// NEXT_PUBLIC_* values are inlined into the client bundle at build time, so the
|
||||
// marketplace prefix (e.g. the frozen fixture stub started by run-cucumber.ts)
|
||||
// must reach both the build environment and the build stamp hash.
|
||||
...(process.env.NEXT_PUBLIC_MARKETPLACE_API_PREFIX
|
||||
? { NEXT_PUBLIC_MARKETPLACE_API_PREFIX: process.env.NEXT_PUBLIC_MARKETPLACE_API_PREFIX }
|
||||
: {}),
|
||||
} satisfies Record<string, string>
|
||||
|
||||
const formatCommand = (command: string, args: string[]) => [command, ...args].join(' ')
|
||||
|
||||
@@ -55,6 +55,10 @@ export const contractLoaders = {
|
||||
remoteFiles: () => import('./remote-files/orpc.gen').then(({ remoteFiles }) => ({ remoteFiles })),
|
||||
resetPassword: () =>
|
||||
import('./reset-password/orpc.gen').then(({ resetPassword }) => ({ resetPassword })),
|
||||
resourceAccessTokens: () =>
|
||||
import('./resource-access-tokens/orpc.gen').then(({ resourceAccessTokens }) => ({
|
||||
resourceAccessTokens,
|
||||
})),
|
||||
ruleCodeGenerate: () =>
|
||||
import('./rule-code-generate/orpc.gen').then(({ ruleCodeGenerate }) => ({ ruleCodeGenerate })),
|
||||
ruleGenerate: () =>
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { oc } from '@orpc/contract'
|
||||
import * as z from 'zod'
|
||||
import {
|
||||
zDeleteResourceAccessTokensByTokenIdRelationsByRelationIdPath,
|
||||
zDeleteResourceAccessTokensByTokenIdRelationsByRelationIdResponse,
|
||||
zGetResourceAccessTokensQuery,
|
||||
zGetResourceAccessTokensResponse,
|
||||
zPatchResourceAccessTokensByTokenIdBody,
|
||||
zPatchResourceAccessTokensByTokenIdPath,
|
||||
zPatchResourceAccessTokensByTokenIdResponse,
|
||||
zPostResourceAccessTokensBody,
|
||||
zPostResourceAccessTokensResponse,
|
||||
} from './zod.gen'
|
||||
|
||||
export const delete_ = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'DELETE',
|
||||
operationId: 'deleteResourceAccessTokensByTokenIdRelationsByRelationId',
|
||||
path: '/resource-access-tokens/{token_id}/relations/{relation_id}',
|
||||
successStatus: 204,
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ params: zDeleteResourceAccessTokensByTokenIdRelationsByRelationIdPath }))
|
||||
.output(zDeleteResourceAccessTokensByTokenIdRelationsByRelationIdResponse)
|
||||
|
||||
export const byRelationId = {
|
||||
delete: delete_,
|
||||
}
|
||||
|
||||
export const relations = {
|
||||
byRelationId,
|
||||
}
|
||||
|
||||
export const patch = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'PATCH',
|
||||
operationId: 'patchResourceAccessTokensByTokenId',
|
||||
path: '/resource-access-tokens/{token_id}',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
body: zPatchResourceAccessTokensByTokenIdBody,
|
||||
params: zPatchResourceAccessTokensByTokenIdPath,
|
||||
}),
|
||||
)
|
||||
.output(zPatchResourceAccessTokensByTokenIdResponse)
|
||||
|
||||
export const byTokenId = {
|
||||
patch,
|
||||
relations,
|
||||
}
|
||||
|
||||
export const get = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getResourceAccessTokens',
|
||||
path: '/resource-access-tokens',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ query: zGetResourceAccessTokensQuery.optional() }))
|
||||
.output(zGetResourceAccessTokensResponse)
|
||||
|
||||
export const post = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postResourceAccessTokens',
|
||||
path: '/resource-access-tokens',
|
||||
successStatus: 201,
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ body: zPostResourceAccessTokensBody }))
|
||||
.output(zPostResourceAccessTokensResponse)
|
||||
|
||||
export const resourceAccessTokens = {
|
||||
get,
|
||||
post,
|
||||
byTokenId,
|
||||
}
|
||||
|
||||
export const contract = {
|
||||
resourceAccessTokens,
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
export type ClientOptions = {
|
||||
baseUrl: `${string}://${string}/console/api` | (string & {})
|
||||
}
|
||||
|
||||
export type ResourceAccessTokenListResponse = {
|
||||
data: Array<ResourceAccessTokenRowResponse>
|
||||
has_more: boolean
|
||||
limit: number
|
||||
page: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export type ResourceAccessTokenCreatePayload = {
|
||||
name: string
|
||||
resources: Array<ResourceAccessTokenResourcePayload>
|
||||
}
|
||||
|
||||
export type ResourceAccessTokenCreateResponse = {
|
||||
data: Array<ResourceAccessTokenRowResponse>
|
||||
token: string
|
||||
}
|
||||
|
||||
export type ResourceAccessTokenUpdatePayload = {
|
||||
name: string
|
||||
resources: Array<ResourceAccessTokenResourcePayload>
|
||||
}
|
||||
|
||||
export type ResourceAccessTokenRowResponse = {
|
||||
created_at: number
|
||||
last_used_at?: number | null
|
||||
masked_token: string
|
||||
name: string
|
||||
relation_id: string
|
||||
resource_id: string
|
||||
resource_name: string
|
||||
resource_type: ResourceAccessTokenResourceType
|
||||
token?: string | null
|
||||
token_id: string
|
||||
track_id: string
|
||||
}
|
||||
|
||||
export type ResourceAccessTokenResourcePayload = {
|
||||
id: string
|
||||
type: ResourceAccessTokenResourceType
|
||||
}
|
||||
|
||||
export type ResourceAccessTokenResourceType = 'app' | 'knowledge'
|
||||
|
||||
export type GetResourceAccessTokensData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
limit?: number
|
||||
page?: number
|
||||
}
|
||||
url: '/resource-access-tokens'
|
||||
}
|
||||
|
||||
export type GetResourceAccessTokensResponses = {
|
||||
200: ResourceAccessTokenListResponse
|
||||
}
|
||||
|
||||
export type GetResourceAccessTokensResponse =
|
||||
GetResourceAccessTokensResponses[keyof GetResourceAccessTokensResponses]
|
||||
|
||||
export type PostResourceAccessTokensData = {
|
||||
body: ResourceAccessTokenCreatePayload
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/resource-access-tokens'
|
||||
}
|
||||
|
||||
export type PostResourceAccessTokensResponses = {
|
||||
201: ResourceAccessTokenCreateResponse
|
||||
}
|
||||
|
||||
export type PostResourceAccessTokensResponse =
|
||||
PostResourceAccessTokensResponses[keyof PostResourceAccessTokensResponses]
|
||||
|
||||
export type PatchResourceAccessTokensByTokenIdData = {
|
||||
body: ResourceAccessTokenUpdatePayload
|
||||
path: {
|
||||
token_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/resource-access-tokens/{token_id}'
|
||||
}
|
||||
|
||||
export type PatchResourceAccessTokensByTokenIdResponses = {
|
||||
200: ResourceAccessTokenListResponse
|
||||
}
|
||||
|
||||
export type PatchResourceAccessTokensByTokenIdResponse =
|
||||
PatchResourceAccessTokensByTokenIdResponses[keyof PatchResourceAccessTokensByTokenIdResponses]
|
||||
|
||||
export type DeleteResourceAccessTokensByTokenIdRelationsByRelationIdData = {
|
||||
body?: never
|
||||
path: {
|
||||
relation_id: string
|
||||
token_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/resource-access-tokens/{token_id}/relations/{relation_id}'
|
||||
}
|
||||
|
||||
export type DeleteResourceAccessTokensByTokenIdRelationsByRelationIdResponses = {
|
||||
204: void
|
||||
}
|
||||
|
||||
export type DeleteResourceAccessTokensByTokenIdRelationsByRelationIdResponse =
|
||||
DeleteResourceAccessTokensByTokenIdRelationsByRelationIdResponses[keyof DeleteResourceAccessTokensByTokenIdRelationsByRelationIdResponses]
|
||||
@@ -0,0 +1,106 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import * as z from 'zod'
|
||||
|
||||
/**
|
||||
* ResourceAccessTokenResourceType
|
||||
*/
|
||||
export const zResourceAccessTokenResourceType = z.enum(['app', 'knowledge'])
|
||||
|
||||
/**
|
||||
* ResourceAccessTokenRowResponse
|
||||
*/
|
||||
export const zResourceAccessTokenRowResponse = z.object({
|
||||
created_at: z.int(),
|
||||
last_used_at: z.int().nullish(),
|
||||
masked_token: z.string(),
|
||||
name: z.string(),
|
||||
relation_id: z.string(),
|
||||
resource_id: z.string(),
|
||||
resource_name: z.string(),
|
||||
resource_type: zResourceAccessTokenResourceType,
|
||||
token: z.string().nullish(),
|
||||
token_id: z.string(),
|
||||
track_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* ResourceAccessTokenListResponse
|
||||
*/
|
||||
export const zResourceAccessTokenListResponse = z.object({
|
||||
data: z.array(zResourceAccessTokenRowResponse),
|
||||
has_more: z.boolean(),
|
||||
limit: z.int(),
|
||||
page: z.int(),
|
||||
total: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* ResourceAccessTokenCreateResponse
|
||||
*/
|
||||
export const zResourceAccessTokenCreateResponse = z.object({
|
||||
data: z.array(zResourceAccessTokenRowResponse),
|
||||
token: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* ResourceAccessTokenResourcePayload
|
||||
*/
|
||||
export const zResourceAccessTokenResourcePayload = z.object({
|
||||
id: z.string(),
|
||||
type: zResourceAccessTokenResourceType,
|
||||
})
|
||||
|
||||
/**
|
||||
* ResourceAccessTokenUpdatePayload
|
||||
*/
|
||||
export const zResourceAccessTokenUpdatePayload = z.object({
|
||||
name: z.string().min(1),
|
||||
resources: z.array(zResourceAccessTokenResourcePayload).min(1),
|
||||
})
|
||||
|
||||
/**
|
||||
* ResourceAccessTokenCreatePayload
|
||||
*/
|
||||
export const zResourceAccessTokenCreatePayload = z.object({
|
||||
name: z.string().min(1),
|
||||
resources: z.array(zResourceAccessTokenResourcePayload).min(1),
|
||||
})
|
||||
|
||||
export const zGetResourceAccessTokensQuery = z.object({
|
||||
limit: z.int().gte(1).lte(100).optional().default(20),
|
||||
page: z.int().gte(1).optional().default(1),
|
||||
})
|
||||
|
||||
/**
|
||||
* Resource access tokens
|
||||
*/
|
||||
export const zGetResourceAccessTokensResponse = zResourceAccessTokenListResponse
|
||||
|
||||
export const zPostResourceAccessTokensBody = zResourceAccessTokenCreatePayload
|
||||
|
||||
/**
|
||||
* Resource access token created
|
||||
*/
|
||||
export const zPostResourceAccessTokensResponse = zResourceAccessTokenCreateResponse
|
||||
|
||||
export const zPatchResourceAccessTokensByTokenIdBody = zResourceAccessTokenUpdatePayload
|
||||
|
||||
export const zPatchResourceAccessTokensByTokenIdPath = z.object({
|
||||
token_id: z.uuid(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Resource access token updated
|
||||
*/
|
||||
export const zPatchResourceAccessTokensByTokenIdResponse = zResourceAccessTokenListResponse
|
||||
|
||||
export const zDeleteResourceAccessTokensByTokenIdRelationsByRelationIdPath = z.object({
|
||||
relation_id: z.uuid(),
|
||||
token_id: z.uuid(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Resource access token relation deleted
|
||||
*/
|
||||
export const zDeleteResourceAccessTokensByTokenIdRelationsByRelationIdResponse = z.void()
|
||||
@@ -37,6 +37,7 @@ import { rag } from './rag/orpc.gen'
|
||||
import { refreshToken } from './refresh-token/orpc.gen'
|
||||
import { remoteFiles } from './remote-files/orpc.gen'
|
||||
import { resetPassword } from './reset-password/orpc.gen'
|
||||
import { resourceAccessTokens } from './resource-access-tokens/orpc.gen'
|
||||
import { ruleCodeGenerate } from './rule-code-generate/orpc.gen'
|
||||
import { ruleGenerate } from './rule-generate/orpc.gen'
|
||||
import { ruleStructuredOutputGenerate } from './rule-structured-output-generate/orpc.gen'
|
||||
@@ -92,6 +93,7 @@ const communityContract = {
|
||||
refreshToken,
|
||||
remoteFiles,
|
||||
resetPassword,
|
||||
resourceAccessTokens,
|
||||
ruleCodeGenerate,
|
||||
ruleGenerate,
|
||||
ruleStructuredOutputGenerate,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
describe('settingsQueryParser', () => {
|
||||
it.each([
|
||||
['roles-and-permissions', 'roles-and-permissions'],
|
||||
['access-token', 'access-token'],
|
||||
['preferences', 'preferences'],
|
||||
['provider', 'provider'],
|
||||
['mcp', 'mcp'],
|
||||
|
||||
@@ -127,6 +127,11 @@ vi.mock('@/app/components/header/account-setting/access-rules-page', () => ({
|
||||
default: () => <div data-testid="access-rules-page" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/resource-access-token-page', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="resource-access-token-page" />,
|
||||
}))
|
||||
|
||||
const baseConsoleState: ConsoleStateFixture = {
|
||||
userProfile: {
|
||||
id: '1',
|
||||
@@ -234,6 +239,9 @@ describe('AccountSetting', () => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'common.settings.permissionSet' }),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'common.settings.accessToken' }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('common.settings.billing'))!.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'appLog.archives.title' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('common.settings.dataSource'))!.not.toBeInTheDocument()
|
||||
@@ -411,6 +419,9 @@ describe('AccountSetting', () => {
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'common.settings.permissionSet' }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'common.settings.accessToken' }),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide role and permission set entries when RBAC is disabled', () => {
|
||||
@@ -425,6 +436,26 @@ describe('AccountSetting', () => {
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'common.settings.permissionSet' }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'common.settings.accessToken' }),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide access tokens from non-owner workspaces', () => {
|
||||
mockConsoleState.current = {
|
||||
...baseConsoleState,
|
||||
currentWorkspace: {
|
||||
...baseConsoleState.currentWorkspace,
|
||||
role: 'admin' as const,
|
||||
},
|
||||
isCurrentWorkspaceOwner: false,
|
||||
}
|
||||
|
||||
renderAccountSetting()
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'common.settings.accessToken' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render direct role pages when RBAC is disabled', () => {
|
||||
@@ -601,6 +632,10 @@ describe('AccountSetting', () => {
|
||||
expect(screen.getByText('common.settings.permissionSetDescription')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('access-rules-page')).toBeInTheDocument()
|
||||
|
||||
// Access Tokens
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.settings.accessToken' }))
|
||||
expect(screen.getByTestId('resource-access-token-page')).toBeInTheDocument()
|
||||
|
||||
// Language
|
||||
fireEvent.click(screen.getByText('common.settings.preferences'))
|
||||
expect(screen.getByText('common.account.general')).toBeInTheDocument()
|
||||
|
||||
@@ -2,6 +2,7 @@ export const ACCOUNT_SETTING_TAB = {
|
||||
MEMBERS: 'members',
|
||||
ROLES_AND_PERMISSIONS: 'roles-and-permissions',
|
||||
PERMISSION_SET: 'permission-set',
|
||||
ACCESS_TOKEN: 'access-token',
|
||||
BILLING: 'billing',
|
||||
WORKFLOW_LOG_ARCHIVES: 'workflow-log-archives',
|
||||
CUSTOM: 'custom',
|
||||
@@ -14,6 +15,7 @@ export const ACCOUNT_SETTING_TAB_VALUES = [
|
||||
ACCOUNT_SETTING_TAB.MEMBERS,
|
||||
ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS,
|
||||
ACCOUNT_SETTING_TAB.PERMISSION_SET,
|
||||
ACCOUNT_SETTING_TAB.ACCESS_TOKEN,
|
||||
ACCOUNT_SETTING_TAB.BILLING,
|
||||
ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES,
|
||||
ACCOUNT_SETTING_TAB.CUSTOM,
|
||||
|
||||
@@ -21,6 +21,7 @@ import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
||||
import {
|
||||
isCurrentWorkspaceDatasetOperatorAtom,
|
||||
isCurrentWorkspaceManagerAtom,
|
||||
isCurrentWorkspaceOwnerAtom,
|
||||
} from '@/context/workspace-state'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
@@ -30,6 +31,7 @@ import AccessRulesPage from './access-rules-page'
|
||||
import MembersPage from './members-page'
|
||||
import PermissionsPage from './permissions-page'
|
||||
import PreferencePage from './preference-page'
|
||||
import ResourceAccessTokenPage from './resource-access-token-page'
|
||||
import WorkflowLogArchivesPage from './workflow-log-archives-page'
|
||||
|
||||
const iconClassName = `
|
||||
@@ -65,6 +67,7 @@ export default function AccountSetting({
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
|
||||
const isCurrentWorkspaceOwner = useAtomValue(isCurrentWorkspaceOwnerAtom)
|
||||
const isCurrentWorkspaceDatasetOperator = useAtomValue(isCurrentWorkspaceDatasetOperatorAtom)
|
||||
const isRbacEnabled = systemFeatures.rbac_enabled
|
||||
const canManageWorkspaceRoles =
|
||||
@@ -84,6 +87,8 @@ export default function AccountSetting({
|
||||
!canManageWorkspaceRoles
|
||||
)
|
||||
return ACCOUNT_SETTING_TAB.MEMBERS
|
||||
if (activeTab === ACCOUNT_SETTING_TAB.ACCESS_TOKEN && !isCurrentWorkspaceOwner)
|
||||
return ACCOUNT_SETTING_TAB.MEMBERS
|
||||
return activeTab
|
||||
})()
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
@@ -108,6 +113,13 @@ export default function AccountSetting({
|
||||
icon: <span className={cn('i-ri-lock-2-line', iconClassName)} />,
|
||||
activeIcon: <span className={cn('i-ri-lock-2-fill', iconClassName)} />,
|
||||
},
|
||||
{
|
||||
key: ACCOUNT_SETTING_TAB.ACCESS_TOKEN,
|
||||
name: t(($) => $['settings.accessToken'], { ns: 'common' }),
|
||||
description: t(($) => $['settings.accessTokenDescription'], { ns: 'common' }),
|
||||
icon: <span className={cn('i-ri-key-2-line', iconClassName)} />,
|
||||
activeIcon: <span className={cn('i-ri-key-2-fill', iconClassName)} />,
|
||||
},
|
||||
{
|
||||
key: ACCOUNT_SETTING_TAB.BILLING,
|
||||
name: t(($) => $['settings.billing'], { ns: 'common' }),
|
||||
@@ -148,6 +160,8 @@ export default function AccountSetting({
|
||||
visibleTabs.push(ACCOUNT_SETTING_TAB.PERMISSION_SET)
|
||||
}
|
||||
|
||||
if (isCurrentWorkspaceOwner) visibleTabs.push(ACCOUNT_SETTING_TAB.ACCESS_TOKEN)
|
||||
|
||||
if (canViewBilling) visibleTabs.push(ACCOUNT_SETTING_TAB.BILLING)
|
||||
|
||||
if (enableReplaceWebAppLogo || systemFeatures.deployment_edition === 'CLOUD')
|
||||
@@ -255,6 +269,7 @@ export default function AccountSetting({
|
||||
<PermissionsPage containerRef={scrollContainerRef} />
|
||||
)}
|
||||
{activeMenu === ACCOUNT_SETTING_TAB.PERMISSION_SET && <AccessRulesPage />}
|
||||
{activeMenu === ACCOUNT_SETTING_TAB.ACCESS_TOKEN && <ResourceAccessTokenPage />}
|
||||
{activeMenu === ACCOUNT_SETTING_TAB.BILLING && <BillingPage />}
|
||||
{activeMenu === ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES && (
|
||||
<WorkflowLogArchivesPage />
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import ResourceAccessTokenPage from '../index'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createResourceAccessToken: vi.fn(),
|
||||
deleteResourceAccessTokenRelation: vi.fn(),
|
||||
listApps: vi.fn(),
|
||||
listDatasets: vi.fn(),
|
||||
listResourceAccessTokens: vi.fn(),
|
||||
updateResourceAccessToken: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/console', () => ({
|
||||
consoleQuery: {
|
||||
apps: {
|
||||
get: {
|
||||
queryOptions: (options?: unknown) => ({
|
||||
enabled: (options as { enabled?: boolean } | undefined)?.enabled,
|
||||
queryFn: mocks.listApps,
|
||||
queryKey: ['apps'],
|
||||
}),
|
||||
},
|
||||
},
|
||||
datasets: {
|
||||
get: {
|
||||
queryOptions: (options?: unknown) => ({
|
||||
enabled: (options as { enabled?: boolean } | undefined)?.enabled,
|
||||
queryFn: mocks.listDatasets,
|
||||
queryKey: ['datasets'],
|
||||
}),
|
||||
},
|
||||
},
|
||||
resourceAccessTokens: {
|
||||
get: {
|
||||
key: () => ['resource-access-tokens'],
|
||||
queryOptions: (options?: unknown) => ({
|
||||
queryFn: () => mocks.listResourceAccessTokens(options),
|
||||
queryKey: ['resource-access-tokens', options],
|
||||
}),
|
||||
},
|
||||
post: {
|
||||
mutationOptions: () => ({
|
||||
mutationFn: mocks.createResourceAccessToken,
|
||||
}),
|
||||
},
|
||||
byTokenId: {
|
||||
patch: {
|
||||
mutationOptions: () => ({
|
||||
mutationFn: mocks.updateResourceAccessToken,
|
||||
}),
|
||||
},
|
||||
relations: {
|
||||
byRelationId: {
|
||||
delete: {
|
||||
mutationOptions: () => ({
|
||||
mutationFn: mocks.deleteResourceAccessTokenRelation,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/notifications', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('copy-to-clipboard', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
const renderWithQueryClient = (children: ReactNode) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return render(<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>)
|
||||
}
|
||||
|
||||
describe('ResourceAccessTokenPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.listResourceAccessTokens.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
created_at: 1787000000,
|
||||
last_used_at: null,
|
||||
masked_token: 'sk-12345...abcd',
|
||||
name: 'Production clients',
|
||||
relation_id: 'relation-1',
|
||||
resource_id: 'app-1',
|
||||
resource_name: 'Support Bot',
|
||||
resource_type: 'app',
|
||||
token_id: 'token-1',
|
||||
track_id: 'track-one',
|
||||
},
|
||||
{
|
||||
created_at: 1787000000,
|
||||
last_used_at: null,
|
||||
masked_token: 'sk-12345...abcd',
|
||||
name: 'Production clients',
|
||||
relation_id: 'relation-2',
|
||||
resource_id: 'dataset-1',
|
||||
resource_name: 'Help Center',
|
||||
resource_type: 'knowledge',
|
||||
token_id: 'token-1',
|
||||
track_id: 'track-one',
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
limit: 20,
|
||||
page: 1,
|
||||
total: 1,
|
||||
})
|
||||
mocks.listApps.mockResolvedValue({
|
||||
data: [{ id: 'app-2', name: 'Sales Bot' }],
|
||||
has_more: false,
|
||||
limit: 100,
|
||||
page: 1,
|
||||
total: 1,
|
||||
})
|
||||
mocks.listDatasets.mockResolvedValue({
|
||||
data: [
|
||||
{ enable_api: true, id: 'dataset-2', name: 'Sales Docs' },
|
||||
{ enable_api: false, id: 'dataset-disabled', name: 'Hidden Docs' },
|
||||
],
|
||||
has_more: false,
|
||||
limit: 100,
|
||||
page: 1,
|
||||
total: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders one row per token with an accessible resource summary', async () => {
|
||||
renderWithQueryClient(<ResourceAccessTokenPage />)
|
||||
|
||||
expect(await screen.findByText('Production clients')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'common.resourceAccessToken.appCount:{"count":1} · common.resourceAccessToken.knowledgeCount:{"count":1}',
|
||||
),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('track-on ...')).toBeInTheDocument()
|
||||
expect(screen.getByText('sk-12345...abcd')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Support Bot')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Help Center')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows bound app and dataset names when hovering over the resource information', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<ResourceAccessTokenPage />)
|
||||
|
||||
await user.hover(
|
||||
await screen.findByRole('button', {
|
||||
name: 'Production clients · common.resourceAccessToken.accessibleResources',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(await screen.findByText('Support Bot')).toBeInTheDocument()
|
||||
expect(
|
||||
within(
|
||||
screen.getByRole('list', { name: 'common.resourceAccessToken.appsSection' }),
|
||||
).getByText('Support Bot'),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(
|
||||
screen.getByRole('list', { name: 'common.resourceAccessToken.knowledgeBasesSection' }),
|
||||
).getByText('Help Center'),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByText('Sales Bot')).not.toBeInTheDocument()
|
||||
expect(mocks.listApps).not.toHaveBeenCalled()
|
||||
expect(mocks.listDatasets).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders the empty state without the table when there are no access tokens', async () => {
|
||||
mocks.listResourceAccessTokens.mockResolvedValue({
|
||||
data: [],
|
||||
has_more: false,
|
||||
limit: 20,
|
||||
page: 1,
|
||||
total: 0,
|
||||
})
|
||||
renderWithQueryClient(<ResourceAccessTokenPage />)
|
||||
|
||||
expect(await screen.findByText('common.resourceAccessToken.empty')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.resourceAccessToken.emptyDescription')).toBeInTheDocument()
|
||||
expect(screen.queryByText('common.resourceAccessToken.name')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByText('common.resourceAccessToken.total:{"total":0}'),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters tokens by bound resource name', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.listResourceAccessTokens.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
created_at: 1787000000,
|
||||
last_used_at: null,
|
||||
masked_token: 'sk-12345...abcd',
|
||||
name: 'Production clients',
|
||||
relation_id: 'relation-1',
|
||||
resource_id: 'app-1',
|
||||
resource_name: 'Support Bot',
|
||||
resource_type: 'app',
|
||||
token_id: 'token-1',
|
||||
track_id: 'track-one',
|
||||
},
|
||||
{
|
||||
created_at: 1787000000,
|
||||
last_used_at: null,
|
||||
masked_token: 'sk-56789...efgh',
|
||||
name: 'CLI automation',
|
||||
relation_id: 'relation-2',
|
||||
resource_id: 'dataset-1',
|
||||
resource_name: 'Help Center',
|
||||
resource_type: 'knowledge',
|
||||
token_id: 'token-2',
|
||||
track_id: 'track-two',
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
limit: 20,
|
||||
page: 1,
|
||||
total: 2,
|
||||
})
|
||||
renderWithQueryClient(<ResourceAccessTokenPage />)
|
||||
|
||||
await screen.findByText('Production clients')
|
||||
await user.type(screen.getByRole('searchbox'), 'help')
|
||||
|
||||
expect(screen.queryByText('Production clients')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('CLI automation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('creates one token for selected app and knowledge resources', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.createResourceAccessToken.mockResolvedValue({
|
||||
data: [],
|
||||
token: 'sk-new-token',
|
||||
})
|
||||
renderWithQueryClient(<ResourceAccessTokenPage />)
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: 'common.resourceAccessToken.createButton' }),
|
||||
)
|
||||
const dialog = screen.getByRole('dialog')
|
||||
expect(within(dialog).getByRole('button', { name: 'common.operation.create' })).toBeDisabled()
|
||||
|
||||
await user.type(within(dialog).getByLabelText('common.resourceAccessToken.name'), 'Partners')
|
||||
await user.click(await within(dialog).findByRole('checkbox', { name: 'Sales Bot' }))
|
||||
await user.click(within(dialog).getByRole('checkbox', { name: 'Sales Docs' }))
|
||||
expect(within(dialog).queryByRole('checkbox', { name: 'Hidden Docs' })).not.toBeInTheDocument()
|
||||
await user.click(within(dialog).getByRole('button', { name: 'common.operation.create' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.createResourceAccessToken).toHaveBeenCalledWith(
|
||||
{
|
||||
body: {
|
||||
name: 'Partners',
|
||||
resources: [
|
||||
{ id: 'app-2', type: 'app' },
|
||||
{ id: 'dataset-2', type: 'knowledge' },
|
||||
],
|
||||
},
|
||||
},
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
expect(await within(dialog).findByText('sk-new-token')).toBeInTheDocument()
|
||||
expect(
|
||||
within(dialog).getByText('common.resourceAccessToken.createdTokenDescription'),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: 'common.resourceAccessToken.done' }),
|
||||
).toBeInTheDocument()
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: 'common.operation.copy' }))
|
||||
await waitFor(() => {
|
||||
expect(copy).toHaveBeenCalledWith('sk-new-token')
|
||||
})
|
||||
})
|
||||
|
||||
it('separates apps and knowledge bases in the create dialog and searches both types', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.listApps.mockResolvedValue({
|
||||
data: [
|
||||
{ id: 'app-2', name: 'Sales Bot' },
|
||||
{ id: 'app-3', name: 'Support Bot' },
|
||||
],
|
||||
has_more: false,
|
||||
limit: 100,
|
||||
page: 1,
|
||||
total: 2,
|
||||
})
|
||||
mocks.listDatasets.mockResolvedValue({
|
||||
data: [
|
||||
{ enable_api: true, id: 'dataset-2', name: 'Sales Docs' },
|
||||
{ enable_api: true, id: 'dataset-3', name: 'Support Docs' },
|
||||
],
|
||||
has_more: false,
|
||||
limit: 100,
|
||||
page: 1,
|
||||
total: 2,
|
||||
})
|
||||
renderWithQueryClient(<ResourceAccessTokenPage />)
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: 'common.resourceAccessToken.createButton' }),
|
||||
)
|
||||
const dialog = screen.getByRole('dialog')
|
||||
expect(
|
||||
await within(dialog).findByText('common.resourceAccessToken.appsSection'),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(dialog).getByText('common.resourceAccessToken.knowledgeBasesSection'),
|
||||
).toBeInTheDocument()
|
||||
|
||||
await user.type(within(dialog).getByRole('searchbox'), 'sales')
|
||||
expect(within(dialog).getByRole('checkbox', { name: 'Sales Bot' })).toBeInTheDocument()
|
||||
expect(within(dialog).getByRole('checkbox', { name: 'Sales Docs' })).toBeInTheDocument()
|
||||
expect(within(dialog).queryByRole('checkbox', { name: 'Support Bot' })).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByRole('checkbox', { name: 'Support Docs' })).not.toBeInTheDocument()
|
||||
|
||||
await user.click(
|
||||
within(dialog).getByRole('tab', { name: 'common.resourceAccessToken.tabApps' }),
|
||||
)
|
||||
expect(within(dialog).getByRole('checkbox', { name: 'Sales Bot' })).toBeInTheDocument()
|
||||
expect(within(dialog).queryByRole('checkbox', { name: 'Sales Docs' })).not.toBeInTheDocument()
|
||||
|
||||
await user.click(
|
||||
within(dialog).getByRole('tab', { name: 'common.resourceAccessToken.tabKnowledgeBases' }),
|
||||
)
|
||||
expect(within(dialog).queryByRole('checkbox', { name: 'Sales Bot' })).not.toBeInTheDocument()
|
||||
expect(within(dialog).getByRole('checkbox', { name: 'Sales Docs' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('edits the token name and selected resources for the selected row', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.updateResourceAccessToken.mockResolvedValue({
|
||||
data: [],
|
||||
has_more: false,
|
||||
limit: 1,
|
||||
page: 1,
|
||||
total: 1,
|
||||
})
|
||||
renderWithQueryClient(<ResourceAccessTokenPage />)
|
||||
|
||||
await screen.findByText('Production clients')
|
||||
await user.click(screen.getAllByRole('button', { name: 'common.operation.edit' })[0]!)
|
||||
const dialog = screen.getByRole('dialog')
|
||||
expect(await within(dialog).findByRole('checkbox', { name: 'Support Bot' })).toBeChecked()
|
||||
expect(within(dialog).getByRole('checkbox', { name: 'Help Center' })).toBeChecked()
|
||||
expect(
|
||||
within(dialog).getByText('common.dynamicSelect.selected:{"count":2}'),
|
||||
).toBeInTheDocument()
|
||||
await user.click(within(dialog).getByRole('checkbox', { name: 'Help Center' }))
|
||||
await user.click(within(dialog).getByRole('checkbox', { name: 'Sales Bot' }))
|
||||
await user.clear(within(dialog).getByLabelText('common.resourceAccessToken.name'))
|
||||
await user.type(within(dialog).getByLabelText('common.resourceAccessToken.name'), 'Renamed')
|
||||
await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.updateResourceAccessToken).toHaveBeenCalledWith(
|
||||
{
|
||||
body: {
|
||||
name: 'Renamed',
|
||||
resources: [
|
||||
{ id: 'app-2', type: 'app' },
|
||||
{ id: 'app-1', type: 'app' },
|
||||
],
|
||||
},
|
||||
params: { token_id: 'token-1' },
|
||||
},
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes the selected token', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.deleteResourceAccessTokenRelation.mockResolvedValue(undefined)
|
||||
renderWithQueryClient(<ResourceAccessTokenPage />)
|
||||
|
||||
await screen.findByText('Production clients')
|
||||
await user.click(screen.getAllByRole('button', { name: 'common.operation.delete' })[0]!)
|
||||
const dialog = screen.getByRole('dialog')
|
||||
await user.click(within(dialog).getByRole('button', { name: 'common.operation.delete' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.deleteResourceAccessTokenRelation).toHaveBeenCalledWith(
|
||||
{
|
||||
params: {
|
||||
relation_id: 'relation-1',
|
||||
token_id: 'token-1',
|
||||
},
|
||||
},
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,808 @@
|
||||
'use client'
|
||||
|
||||
import type {
|
||||
ResourceAccessTokenResourcePayload,
|
||||
ResourceAccessTokenRowResponse,
|
||||
} from '@dify/contracts/api/console/resource-access-tokens/types.gen'
|
||||
import type { FormEvent } from 'react'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Checkbox } from '@langgenius/dify-ui/checkbox'
|
||||
import { Dialog, DialogClose, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { Field, FieldError, FieldLabel } from '@langgenius/dify-ui/field'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { Tabs, TabsList, TabsPanel, TabsTab } from '@langgenius/dify-ui/tabs'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import { SearchInput } from '@/app/components/base/search-input'
|
||||
import { SkeletonRectangle } from '@/app/components/base/skeleton'
|
||||
import { toast } from '@/app/notifications'
|
||||
import { consoleQuery } from '@/service/console'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
type DialogState =
|
||||
| {
|
||||
mode: 'create'
|
||||
}
|
||||
| {
|
||||
mode: 'edit'
|
||||
row: TokenTableRow
|
||||
}
|
||||
| null
|
||||
|
||||
type ResourceCandidate = ResourceAccessTokenResourcePayload & {
|
||||
name: string
|
||||
}
|
||||
|
||||
type ResourceTab = 'all' | 'app' | 'knowledge'
|
||||
|
||||
type TokenTableRow = {
|
||||
tokenId: string
|
||||
name: string
|
||||
trackId: string
|
||||
maskedToken: string
|
||||
createdAt?: number | null
|
||||
relations: ResourceAccessTokenRowResponse[]
|
||||
}
|
||||
|
||||
const candidateKey = (candidate: ResourceAccessTokenResourcePayload) =>
|
||||
`${candidate.type}:${candidate.id}`
|
||||
|
||||
const rowResourceKey = (row: ResourceAccessTokenRowResponse) =>
|
||||
candidateKey({
|
||||
id: row.resource_id,
|
||||
type: row.resource_type,
|
||||
})
|
||||
|
||||
const formatTime = (timestamp?: number | null) => {
|
||||
if (!timestamp) return '-'
|
||||
|
||||
return new Date(timestamp * 1000).toLocaleDateString(undefined, {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
const formatTrackingId = (trackId: string) => {
|
||||
if (trackId.length <= 8) return trackId
|
||||
|
||||
return `${trackId.slice(0, 8)} ...`
|
||||
}
|
||||
|
||||
const getTokenRows = (rows: ResourceAccessTokenRowResponse[]) => {
|
||||
const rowMap = new Map<string, TokenTableRow>()
|
||||
|
||||
rows.forEach((row) => {
|
||||
const tokenRow = rowMap.get(row.token_id)
|
||||
if (tokenRow) {
|
||||
tokenRow.relations.push(row)
|
||||
return
|
||||
}
|
||||
|
||||
rowMap.set(row.token_id, {
|
||||
tokenId: row.token_id,
|
||||
name: row.name,
|
||||
trackId: row.track_id,
|
||||
maskedToken: row.masked_token,
|
||||
createdAt: row.created_at,
|
||||
relations: [row],
|
||||
})
|
||||
})
|
||||
|
||||
return Array.from(rowMap.values())
|
||||
}
|
||||
|
||||
const getResourceSummary = (
|
||||
relations: ResourceAccessTokenRowResponse[],
|
||||
t: ReturnType<typeof useTranslation>['t'],
|
||||
) => {
|
||||
const appCount = relations.filter((relation) => relation.resource_type === 'app').length
|
||||
const knowledgeCount = relations.filter(
|
||||
(relation) => relation.resource_type === 'knowledge',
|
||||
).length
|
||||
const parts: string[] = []
|
||||
|
||||
if (appCount)
|
||||
parts.push(
|
||||
t(
|
||||
($) => $[appCount === 1 ? 'resourceAccessToken.appCount' : 'resourceAccessToken.appsCount'],
|
||||
{ ns: 'common', count: appCount },
|
||||
),
|
||||
)
|
||||
if (knowledgeCount)
|
||||
parts.push(
|
||||
t(
|
||||
($) =>
|
||||
$[
|
||||
knowledgeCount === 1
|
||||
? 'resourceAccessToken.knowledgeCount'
|
||||
: 'resourceAccessToken.knowledgesCount'
|
||||
],
|
||||
{ ns: 'common', count: knowledgeCount },
|
||||
),
|
||||
)
|
||||
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
function useInvalidateResourceAccessTokens() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return () =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: consoleQuery.resourceAccessTokens.get.key(),
|
||||
})
|
||||
}
|
||||
|
||||
function ResourceAccessTokenDialog({
|
||||
open,
|
||||
state,
|
||||
onOpenChange,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean
|
||||
state: DialogState
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const isCreate = state?.mode === 'create'
|
||||
const isEdit = state?.mode === 'edit'
|
||||
const [name, setName] = useState(isEdit ? state.row.name : '')
|
||||
const [selectedResources, setSelectedResources] = useState<Set<string>>(
|
||||
() => new Set(isEdit ? state.row.relations.map(rowResourceKey) : []),
|
||||
)
|
||||
const [resourceTab, setResourceTab] = useState<ResourceTab>('all')
|
||||
const [resourceSearchText, setResourceSearchText] = useState('')
|
||||
const [createdToken, setCreatedToken] = useState<string | null>(null)
|
||||
const invalidateResourceAccessTokens = useInvalidateResourceAccessTokens()
|
||||
const createMutation = useMutation(consoleQuery.resourceAccessTokens.post.mutationOptions())
|
||||
const updateMutation = useMutation(
|
||||
consoleQuery.resourceAccessTokens.byTokenId.patch.mutationOptions(),
|
||||
)
|
||||
const appsQuery = useQuery(
|
||||
consoleQuery.apps.get.queryOptions({
|
||||
enabled: isCreate || isEdit,
|
||||
input: {
|
||||
query: {
|
||||
limit: 100,
|
||||
page: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
const datasetsQuery = useQuery(
|
||||
consoleQuery.datasets.get.queryOptions({
|
||||
enabled: isCreate || isEdit,
|
||||
input: {
|
||||
query: {
|
||||
include_all: true,
|
||||
limit: 100,
|
||||
page: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
const selectedRelationCandidates = useMemo<ResourceCandidate[]>(
|
||||
() =>
|
||||
isEdit
|
||||
? state.row.relations.map((relation) => ({
|
||||
id: relation.resource_id,
|
||||
name: relation.resource_name,
|
||||
type: relation.resource_type,
|
||||
}))
|
||||
: [],
|
||||
[isEdit, state],
|
||||
)
|
||||
const appCandidates = useMemo<ResourceCandidate[]>(() => {
|
||||
const candidates: ResourceCandidate[] =
|
||||
appsQuery.data?.data.map((app) => ({
|
||||
id: app.id,
|
||||
name: app.name,
|
||||
type: 'app' as const,
|
||||
})) ?? []
|
||||
|
||||
selectedRelationCandidates
|
||||
.filter((candidate) => candidate.type === 'app')
|
||||
.forEach((candidate) => {
|
||||
if (!candidates.some((item) => item.id === candidate.id)) candidates.push(candidate)
|
||||
})
|
||||
|
||||
return candidates
|
||||
}, [appsQuery.data?.data, selectedRelationCandidates])
|
||||
const knowledgeCandidates = useMemo<ResourceCandidate[]>(() => {
|
||||
const candidates: ResourceCandidate[] =
|
||||
datasetsQuery.data?.data
|
||||
.filter((dataset) => dataset.enable_api)
|
||||
.map((dataset) => ({
|
||||
id: dataset.id,
|
||||
name: dataset.name,
|
||||
type: 'knowledge' as const,
|
||||
})) ?? []
|
||||
|
||||
selectedRelationCandidates
|
||||
.filter((candidate) => candidate.type === 'knowledge')
|
||||
.forEach((candidate) => {
|
||||
if (!candidates.some((item) => item.id === candidate.id)) candidates.push(candidate)
|
||||
})
|
||||
|
||||
return candidates
|
||||
}, [datasetsQuery.data?.data, selectedRelationCandidates])
|
||||
const resourceCandidates = useMemo(
|
||||
() => [...appCandidates, ...knowledgeCandidates],
|
||||
[appCandidates, knowledgeCandidates],
|
||||
)
|
||||
const filteredAppCandidates = useMemo(() => {
|
||||
const keyword = resourceSearchText.trim().toLowerCase()
|
||||
if (!keyword) return appCandidates
|
||||
|
||||
return appCandidates.filter((candidate) => candidate.name.toLowerCase().includes(keyword))
|
||||
}, [appCandidates, resourceSearchText])
|
||||
const filteredKnowledgeCandidates = useMemo(() => {
|
||||
const keyword = resourceSearchText.trim().toLowerCase()
|
||||
if (!keyword) return knowledgeCandidates
|
||||
|
||||
return knowledgeCandidates.filter((candidate) => candidate.name.toLowerCase().includes(keyword))
|
||||
}, [knowledgeCandidates, resourceSearchText])
|
||||
const isSaving = createMutation.isPending || updateMutation.isPending
|
||||
const nameLabel = t(($) => $['resourceAccessToken.name'], { ns: 'common' })
|
||||
|
||||
const toggleResource = (candidate: ResourceCandidate) => {
|
||||
const key = candidateKey(candidate)
|
||||
setSelectedResources((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const renderCandidate = (candidate: ResourceCandidate) => {
|
||||
const key = candidateKey(candidate)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex min-h-10 w-full items-center gap-3 border-b border-divider-subtle px-3 text-left last:border-b-0 hover:bg-state-base-hover"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedResources.has(key)}
|
||||
aria-label={candidate.name}
|
||||
onCheckedChange={() => toggleResource(candidate)}
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className={
|
||||
candidate.type === 'app'
|
||||
? 'i-ri-apps-2-line size-4 text-text-tertiary'
|
||||
: 'i-ri-book-2-line size-4 text-text-tertiary'
|
||||
}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate system-sm-medium text-text-primary">
|
||||
{candidate.name}
|
||||
</span>
|
||||
<span className="system-xs-medium-uppercase text-text-tertiary">
|
||||
{candidate.type === 'app'
|
||||
? t(($) => $['resourceAccessToken.app'], { ns: 'common' })
|
||||
: t(($) => $['resourceAccessToken.knowledge'], { ns: 'common' })}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderCandidateSection = (title: string, candidates: ResourceCandidate[]) => {
|
||||
if (!candidates.length) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex h-8 items-center border-b border-divider-subtle px-3 system-xs-medium-uppercase text-text-tertiary">
|
||||
{title}
|
||||
</div>
|
||||
{candidates.map(renderCandidate)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
const normalizedName = name.trim()
|
||||
if (!normalizedName) return
|
||||
|
||||
const resources = resourceCandidates.filter((candidate) =>
|
||||
selectedResources.has(candidateKey(candidate)),
|
||||
)
|
||||
if (!resources.length) return
|
||||
|
||||
if (isEdit) {
|
||||
updateMutation.mutate(
|
||||
{
|
||||
body: {
|
||||
name: normalizedName,
|
||||
resources: resources.map(({ id, type }) => ({ id, type })),
|
||||
},
|
||||
params: {
|
||||
token_id: state.row.tokenId,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' }))
|
||||
void invalidateResourceAccessTokens()
|
||||
onSaved()
|
||||
},
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
createMutation.mutate(
|
||||
{
|
||||
body: {
|
||||
name: normalizedName,
|
||||
resources: resources.map(({ id, type }) => ({ id, type })),
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
toast.success(t(($) => $['resourceAccessToken.created'], { ns: 'common' }))
|
||||
void invalidateResourceAccessTokens()
|
||||
setCreatedToken(result.token)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const copyCreatedToken = () => {
|
||||
if (!createdToken) return
|
||||
|
||||
copy(createdToken)
|
||||
toast.success(t(($) => $['resourceAccessToken.copied'], { ns: 'common' }))
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
|
||||
<DialogContent
|
||||
backdropProps={{ forceRender: true }}
|
||||
className="w-160 border-none p-8 pb-6 text-left"
|
||||
>
|
||||
<DialogClose
|
||||
render={
|
||||
<IconButton
|
||||
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
|
||||
size="lg"
|
||||
className="absolute inset-e-6 top-6"
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-line size-4" />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
<DialogTitle className="mb-5 pr-8 text-xl font-semibold text-text-primary">
|
||||
{isEdit
|
||||
? t(($) => $['resourceAccessToken.editTitle'], { ns: 'common' })
|
||||
: createdToken
|
||||
? t(($) => $['resourceAccessToken.createdToken'], { ns: 'common' })
|
||||
: t(($) => $['resourceAccessToken.createTitle'], { ns: 'common' })}
|
||||
</DialogTitle>
|
||||
|
||||
{createdToken ? (
|
||||
<div className="grid gap-5">
|
||||
<div className="grid gap-2">
|
||||
<div className="system-sm-regular text-text-tertiary">
|
||||
{t(($) => $['resourceAccessToken.createdTokenDescription'], { ns: 'common' })}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2 rounded-lg border border-components-panel-border bg-components-input-bg-normal p-2">
|
||||
<code className="min-w-0 flex-1 truncate px-1 font-mono text-sm text-text-primary">
|
||||
{createdToken}
|
||||
</code>
|
||||
<IconButton
|
||||
size="md"
|
||||
aria-label={t(($) => $['operation.copy'], { ns: 'common' })}
|
||||
onClick={copyCreatedToken}
|
||||
>
|
||||
<span aria-hidden className="i-ri-file-copy-line size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => onOpenChange(false)}>
|
||||
{t(($) => $['resourceAccessToken.done'], { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form className="grid gap-5" onSubmit={handleSubmit}>
|
||||
<Field name="name">
|
||||
<FieldLabel>{nameLabel}</FieldLabel>
|
||||
<Input
|
||||
required
|
||||
value={name}
|
||||
placeholder={t(($) => $['resourceAccessToken.namePlaceholder'], { ns: 'common' })}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
<FieldError match="valueMissing">
|
||||
{t(($) => $['errorMsg.fieldRequired'], { ns: 'common', field: nameLabel })}
|
||||
</FieldError>
|
||||
</Field>
|
||||
|
||||
{(isCreate || isEdit) && (
|
||||
<div className="grid gap-2">
|
||||
<div className="system-sm-medium text-text-secondary">
|
||||
{t(($) => $['resourceAccessToken.resources'], { ns: 'common' })}
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<SearchInput
|
||||
value={resourceSearchText}
|
||||
onValueChange={setResourceSearchText}
|
||||
placeholder={t(($) => $['resourceAccessToken.searchResourcesPlaceholder'], {
|
||||
ns: 'common',
|
||||
})}
|
||||
/>
|
||||
<Tabs
|
||||
value={resourceTab}
|
||||
onValueChange={(value) => setResourceTab(value as ResourceTab)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<TabsList className="gap-5">
|
||||
<TabsTab value="all" className="py-1.5 system-sm-semibold">
|
||||
{t(($) => $['resourceAccessToken.tabAll'], { ns: 'common' })}
|
||||
</TabsTab>
|
||||
<TabsTab value="app" className="py-1.5 system-sm-semibold">
|
||||
{t(($) => $['resourceAccessToken.tabApps'], { ns: 'common' })}
|
||||
</TabsTab>
|
||||
<TabsTab value="knowledge" className="py-1.5 system-sm-semibold">
|
||||
{t(($) => $['resourceAccessToken.tabKnowledgeBases'], { ns: 'common' })}
|
||||
</TabsTab>
|
||||
</TabsList>
|
||||
<div className="rounded-full bg-state-accent-hover px-3 py-1 system-sm-semibold text-text-accent">
|
||||
{t(($) => $['dynamicSelect.selected'], {
|
||||
ns: 'common',
|
||||
count: selectedResources.size,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-72 overflow-y-auto rounded-lg border border-components-panel-border">
|
||||
{(appsQuery.isPending || datasetsQuery.isPending) && (
|
||||
<div className="grid gap-2 p-3">
|
||||
<SkeletonRectangle className="h-8 w-full animate-pulse" />
|
||||
<SkeletonRectangle className="h-8 w-full animate-pulse" />
|
||||
<SkeletonRectangle className="h-8 w-full animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
{!appsQuery.isPending && !datasetsQuery.isPending && (
|
||||
<>
|
||||
<TabsPanel value="all">
|
||||
{renderCandidateSection(
|
||||
t(($) => $['resourceAccessToken.appsSection'], { ns: 'common' }),
|
||||
filteredAppCandidates,
|
||||
)}
|
||||
{renderCandidateSection(
|
||||
t(($) => $['resourceAccessToken.knowledgeBasesSection'], {
|
||||
ns: 'common',
|
||||
}),
|
||||
filteredKnowledgeCandidates,
|
||||
)}
|
||||
</TabsPanel>
|
||||
<TabsPanel value="app">
|
||||
{filteredAppCandidates.map(renderCandidate)}
|
||||
</TabsPanel>
|
||||
<TabsPanel value="knowledge">
|
||||
{filteredKnowledgeCandidates.map(renderCandidate)}
|
||||
</TabsPanel>
|
||||
</>
|
||||
)}
|
||||
{!appsQuery.isPending &&
|
||||
!datasetsQuery.isPending &&
|
||||
((resourceTab === 'all' &&
|
||||
filteredAppCandidates.length === 0 &&
|
||||
filteredKnowledgeCandidates.length === 0) ||
|
||||
(resourceTab === 'app' && filteredAppCandidates.length === 0) ||
|
||||
(resourceTab === 'knowledge' &&
|
||||
filteredKnowledgeCandidates.length === 0)) && (
|
||||
<div className="p-6 text-center system-sm-regular text-text-tertiary">
|
||||
{resourceCandidates.length === 0
|
||||
? t(($) => $['resourceAccessToken.noResources'], { ns: 'common' })
|
||||
: t(($) => $['resourceAccessToken.noResourceSearchResults'], {
|
||||
ns: 'common',
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button type="button" onClick={() => onOpenChange(false)}>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={
|
||||
isSaving ||
|
||||
!name.trim() ||
|
||||
selectedResources.size === 0 ||
|
||||
resourceCandidates.length === 0
|
||||
}
|
||||
>
|
||||
{isEdit
|
||||
? t(($) => $['operation.save'], { ns: 'common' })
|
||||
: t(($) => $['operation.create'], { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function ResourceAccessTokenRow({
|
||||
row,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
row: TokenTableRow
|
||||
onEdit: (row: TokenTableRow) => void
|
||||
onDelete: (row: ResourceAccessTokenRowResponse) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const editRow = row.relations[0]
|
||||
const resourceSummary = getResourceSummary(row.relations, t)
|
||||
|
||||
if (!editRow) return null
|
||||
|
||||
return (
|
||||
<div className="grid min-h-12 grid-cols-[minmax(140px,1.15fr)_minmax(140px,1fr)_minmax(140px,1fr)_minmax(180px,1.2fr)_minmax(120px,0.8fr)_80px] items-center gap-6 border-b border-divider-subtle text-sm last:border-b-0">
|
||||
<div className="min-w-0 truncate font-medium text-text-primary">{row.name}</div>
|
||||
<div className="min-w-0 truncate font-mono text-[13px] text-text-secondary">
|
||||
{formatTrackingId(row.trackId)}
|
||||
</div>
|
||||
<div className="min-w-0 truncate font-mono text-[13px] text-text-primary">
|
||||
{row.maskedToken}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<div className="min-w-0 truncate text-text-secondary">
|
||||
{resourceSummary ||
|
||||
t(($) => $['resourceAccessToken.noAccessibleResources'], { ns: 'common' })}
|
||||
</div>
|
||||
<Infotip
|
||||
iconVariant="information"
|
||||
aria-label={`${row.name} · ${t(($) => $['resourceAccessToken.accessibleResources'], { ns: 'common' })}`}
|
||||
>
|
||||
<div className="grid max-h-60 min-w-48 gap-3 overflow-y-auto">
|
||||
{(['app', 'knowledge'] as const).map((type) => {
|
||||
const resources = row.relations.filter((relation) => relation.resource_type === type)
|
||||
if (resources.length === 0) return null
|
||||
const label = t(
|
||||
($) =>
|
||||
$[
|
||||
type === 'app'
|
||||
? 'resourceAccessToken.appsSection'
|
||||
: 'resourceAccessToken.knowledgeBasesSection'
|
||||
],
|
||||
{ ns: 'common' },
|
||||
)
|
||||
return (
|
||||
<div key={type}>
|
||||
<div className="mb-1 system-xs-semibold text-text-secondary">{label}</div>
|
||||
<ul aria-label={label} className="grid gap-1">
|
||||
{resources.map((resource) => (
|
||||
<li key={resource.relation_id} className="wrap-break-word">
|
||||
{resource.resource_name || resource.resource_id}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Infotip>
|
||||
</div>
|
||||
<div className="min-w-0 truncate text-text-secondary">{formatTime(row.createdAt)}</div>
|
||||
<div className="flex items-center gap-1 text-text-tertiary">
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
aria-label={t(($) => $['operation.edit'], { ns: 'common' })}
|
||||
onClick={() => onEdit(row)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-edit-line size-4" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
aria-label={t(($) => $['operation.delete'], { ns: 'common' })}
|
||||
onClick={() => onDelete(editRow)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-delete-bin-line size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ResourceAccessTokenPage() {
|
||||
const { t } = useTranslation()
|
||||
const [page, setPage] = useState(1)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [dialogState, setDialogState] = useState<DialogState>(null)
|
||||
const [deletingRow, setDeletingRow] = useState<ResourceAccessTokenRowResponse | null>(null)
|
||||
const invalidateResourceAccessTokens = useInvalidateResourceAccessTokens()
|
||||
const deleteMutation = useMutation(
|
||||
consoleQuery.resourceAccessTokens.byTokenId.relations.byRelationId.delete.mutationOptions(),
|
||||
)
|
||||
const tokensQuery = useQuery(
|
||||
consoleQuery.resourceAccessTokens.get.queryOptions({
|
||||
input: {
|
||||
query: {
|
||||
limit: PAGE_SIZE,
|
||||
page,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
const rows = tokensQuery.data?.data
|
||||
const total = tokensQuery.data?.total ?? 0
|
||||
const tokenRows = useMemo(() => getTokenRows(rows ?? []), [rows])
|
||||
const isEmptyList = !tokensQuery.isPending && total === 0 && !searchText.trim()
|
||||
const filteredRows = useMemo(() => {
|
||||
const keyword = searchText.trim().toLowerCase()
|
||||
if (!keyword) return tokenRows
|
||||
|
||||
return tokenRows.filter((row) => {
|
||||
const searchableText = [
|
||||
row.name,
|
||||
row.trackId,
|
||||
row.maskedToken,
|
||||
...row.relations.map((relation) => relation.resource_name),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
return searchableText.includes(keyword)
|
||||
})
|
||||
}, [searchText, tokenRows])
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!deletingRow) return
|
||||
|
||||
deleteMutation.mutate(
|
||||
{
|
||||
params: {
|
||||
relation_id: deletingRow.relation_id,
|
||||
token_id: deletingRow.token_id,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t(($) => $['resourceAccessToken.deleted'], { ns: 'common' }))
|
||||
setDeletingRow(null)
|
||||
void invalidateResourceAccessTokens()
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<SearchInput
|
||||
className="w-80"
|
||||
placeholder={t(($) => $['resourceAccessToken.searchPlaceholder'], { ns: 'common' })}
|
||||
value={searchText}
|
||||
onValueChange={setSearchText}
|
||||
/>
|
||||
<Button variant="primary" onClick={() => setDialogState({ mode: 'create' })}>
|
||||
<span aria-hidden className="i-ri-add-line size-4" />
|
||||
{t(($) => $['resourceAccessToken.createButton'], { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isEmptyList ? (
|
||||
<div className="flex min-h-[360px] items-center justify-center text-center">
|
||||
<div className="grid max-w-[520px] gap-3">
|
||||
<div className="title-md-semi-bold text-text-primary">
|
||||
{t(($) => $['resourceAccessToken.empty'], { ns: 'common' })}
|
||||
</div>
|
||||
<div className="system-md-regular text-text-tertiary">
|
||||
{t(($) => $['resourceAccessToken.emptyDescription'], { ns: 'common' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="grid min-w-[900px] grid-cols-[minmax(140px,1.15fr)_minmax(140px,1fr)_minmax(140px,1fr)_minmax(180px,1.2fr)_minmax(120px,0.8fr)_80px] gap-6 border-b border-divider-subtle pb-3 system-xs-medium-uppercase text-text-tertiary">
|
||||
<div>{t(($) => $['resourceAccessToken.name'], { ns: 'common' })}</div>
|
||||
<div>{t(($) => $['resourceAccessToken.trackingId'], { ns: 'common' })}</div>
|
||||
<div>{t(($) => $['resourceAccessToken.token'], { ns: 'common' })}</div>
|
||||
<div>{t(($) => $['resourceAccessToken.accessibleResources'], { ns: 'common' })}</div>
|
||||
<div>{t(($) => $['resourceAccessToken.createdColumn'], { ns: 'common' })}</div>
|
||||
<div>{t(($) => $['resourceAccessToken.actions'], { ns: 'common' })}</div>
|
||||
</div>
|
||||
|
||||
{tokensQuery.isPending && (
|
||||
<div className="grid gap-2 py-4">
|
||||
<SkeletonRectangle className="h-12 w-full animate-pulse" />
|
||||
<SkeletonRectangle className="h-12 w-full animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
{!tokensQuery.isPending &&
|
||||
filteredRows.map((row) => (
|
||||
<ResourceAccessTokenRow
|
||||
key={row.tokenId}
|
||||
row={row}
|
||||
onEdit={(row) => setDialogState({ mode: 'edit', row })}
|
||||
onDelete={setDeletingRow}
|
||||
/>
|
||||
))}
|
||||
{!tokensQuery.isPending && filteredRows.length === 0 && (
|
||||
<div className="p-10 text-center system-sm-regular text-text-tertiary">
|
||||
{t(($) => $['resourceAccessToken.empty'], { ns: 'common' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEmptyList && (
|
||||
<div className="flex items-center justify-between system-xs-regular text-text-tertiary">
|
||||
<span>{t(($) => $['resourceAccessToken.total'], { ns: 'common', total })}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="small" disabled={page <= 1} onClick={() => setPage((value) => value - 1)}>
|
||||
{t(($) => $['resourceAccessToken.previous'], { ns: 'common' })}
|
||||
</Button>
|
||||
<span>{page}</span>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={!tokensQuery.data?.has_more}
|
||||
onClick={() => setPage((value) => value + 1)}
|
||||
>
|
||||
{t(($) => $['resourceAccessToken.next'], { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dialogState && (
|
||||
<ResourceAccessTokenDialog
|
||||
open
|
||||
state={dialogState}
|
||||
onOpenChange={(open) => !open && setDialogState(null)}
|
||||
onSaved={() => {
|
||||
setDialogState(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog open={!!deletingRow} onOpenChange={(open) => !open && setDeletingRow(null)}>
|
||||
<DialogContent className="w-120 p-6 text-left" backdropProps={{ forceRender: true }}>
|
||||
<DialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||
{t(($) => $['resourceAccessToken.deleteTitle'], { ns: 'common' })}
|
||||
</DialogTitle>
|
||||
<div className="mt-2 system-sm-regular text-text-tertiary">
|
||||
{t(($) => $['resourceAccessToken.deleteDescription'], { ns: 'common' })}
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Button onClick={() => setDeletingRow(null)}>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
tone="destructive"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={confirmDelete}
|
||||
>
|
||||
{t(($) => $['operation.delete'], { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -112,7 +112,7 @@ describe('HydrateQueryClient', () => {
|
||||
expect(state.queries[0]?.queryKey).toEqual([
|
||||
'marketplace',
|
||||
'collections',
|
||||
{ input: { query: {} } },
|
||||
{ input: { query: { limit: 20 } } },
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -311,12 +311,13 @@ describe('getMarketplacePluginsByCollectionId', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should omit a preview limit when query is omitted', async () => {
|
||||
it('should send the warmed preview limit when query is omitted', async () => {
|
||||
mockCollectionPlugins.mockResolvedValueOnce({
|
||||
data: { plugins: [] },
|
||||
})
|
||||
|
||||
const { getMarketplacePluginsByCollectionId } = await import('../utils')
|
||||
const { COLLECTION_PREVIEW_PLUGIN_LIMIT, getMarketplacePluginsByCollectionId } =
|
||||
await import('../utils')
|
||||
await getMarketplacePluginsByCollectionId('test-collection')
|
||||
|
||||
expect(mockCollectionPlugins).toHaveBeenCalledWith(
|
||||
@@ -324,7 +325,7 @@ describe('getMarketplacePluginsByCollectionId', () => {
|
||||
params: {
|
||||
collectionId: 'test-collection',
|
||||
},
|
||||
body: {},
|
||||
body: { limit: COLLECTION_PREVIEW_PLUGIN_LIMIT },
|
||||
},
|
||||
expect.objectContaining({
|
||||
signal: undefined,
|
||||
@@ -362,7 +363,8 @@ describe('getMarketplaceCollectionsAndPlugins', () => {
|
||||
mockCollections.mockResolvedValueOnce({ data: { collections: mockCollectionData } })
|
||||
mockCollectionPlugins.mockResolvedValue({ data: { plugins: mockPluginData } })
|
||||
|
||||
const { getMarketplaceCollectionsAndPlugins } = await import('../utils')
|
||||
const { COLLECTION_PREVIEW_PLUGIN_LIMIT, getMarketplaceCollectionsAndPlugins } =
|
||||
await import('../utils')
|
||||
const result = await getMarketplaceCollectionsAndPlugins({
|
||||
condition: 'category=tool',
|
||||
type: 'plugin',
|
||||
@@ -376,13 +378,14 @@ describe('getMarketplaceCollectionsAndPlugins', () => {
|
||||
body: {
|
||||
condition: 'category=tool',
|
||||
type: 'plugin',
|
||||
limit: COLLECTION_PREVIEW_PLUGIN_LIMIT,
|
||||
},
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
|
||||
it('posts without a preview limit when the catalog has no extra filters', async () => {
|
||||
it('posts the warmed preview limit when the catalog has no extra filters', async () => {
|
||||
mockCollections.mockResolvedValueOnce({
|
||||
data: {
|
||||
collections: [
|
||||
@@ -399,13 +402,14 @@ describe('getMarketplaceCollectionsAndPlugins', () => {
|
||||
})
|
||||
mockCollectionPlugins.mockResolvedValue({ data: { plugins: [] } })
|
||||
|
||||
const { getMarketplaceCollectionsAndPlugins } = await import('../utils')
|
||||
const { COLLECTION_PREVIEW_PLUGIN_LIMIT, getMarketplaceCollectionsAndPlugins } =
|
||||
await import('../utils')
|
||||
await getMarketplaceCollectionsAndPlugins()
|
||||
|
||||
expect(mockCollectionPlugins).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
params: { collectionId: 'featured' },
|
||||
body: {},
|
||||
body: { limit: COLLECTION_PREVIEW_PLUGIN_LIMIT },
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
@@ -486,18 +490,22 @@ describe('getMarketplaceCollectionsAndPlugins', () => {
|
||||
})
|
||||
|
||||
describe('getCollectionsParams', () => {
|
||||
it('should return an empty query for all category', async () => {
|
||||
const { getCollectionsParams } = await import('../utils')
|
||||
expect(getCollectionsParams(PLUGIN_TYPE_SEARCH_MAP.all)).toEqual({})
|
||||
it('should return the warmed preview limit for all category', async () => {
|
||||
const { COLLECTION_PREVIEW_PLUGIN_LIMIT, getCollectionsParams } = await import('../utils')
|
||||
expect(getCollectionsParams(PLUGIN_TYPE_SEARCH_MAP.all)).toEqual({
|
||||
limit: COLLECTION_PREVIEW_PLUGIN_LIMIT,
|
||||
})
|
||||
expect(COLLECTION_PREVIEW_PLUGIN_LIMIT).toBe(20)
|
||||
})
|
||||
|
||||
it('should return category, condition, and type for tool category', async () => {
|
||||
const { getCollectionsParams } = await import('../utils')
|
||||
it('should return category, condition, type, and preview limit for tool category', async () => {
|
||||
const { COLLECTION_PREVIEW_PLUGIN_LIMIT, getCollectionsParams } = await import('../utils')
|
||||
const result = getCollectionsParams(PLUGIN_TYPE_SEARCH_MAP.tool)
|
||||
expect(result).toEqual({
|
||||
category: PluginCategoryEnum.tool,
|
||||
condition: 'category=tool',
|
||||
type: 'plugin',
|
||||
limit: COLLECTION_PREVIEW_PLUGIN_LIMIT,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { render } from 'vitest-browser-react'
|
||||
import HomeCatalogNavigation from '../home-catalog-navigation'
|
||||
import HomeCatalogTabs from '../home-catalog-tabs'
|
||||
import { HomeStickyStateProvider } from '../home-sticky-state-provider'
|
||||
import styles from '../home-sticky.module.css'
|
||||
|
||||
describe('Marketplace home catalog alignment', () => {
|
||||
it('aligns catalog tabs and filters with the content container', async () => {
|
||||
const screen = await render(
|
||||
<HomeStickyStateProvider>
|
||||
<div className="w-[1200px]" data-marketplace-standalone>
|
||||
<HomeCatalogNavigation
|
||||
isMarketplacePlatform
|
||||
catalogCategories={
|
||||
<div data-testid="catalog-filter" role="group" aria-label="Categories" />
|
||||
}
|
||||
catalogTabs={
|
||||
<HomeCatalogTabs
|
||||
isMarketplacePlatform
|
||||
labels={{ plugins: 'Plugins', templates: 'Templates' }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<div className={`px-8 ${styles.catalogContent}`}>
|
||||
<div role="region" aria-label="Catalog content" className="h-10" />
|
||||
</div>
|
||||
</div>
|
||||
</HomeStickyStateProvider>,
|
||||
)
|
||||
|
||||
const contentLeft = screen
|
||||
.getByRole('region', { name: 'Catalog content' })
|
||||
.element()
|
||||
.getBoundingClientRect().left
|
||||
const tabsLeft = screen.getByRole('navigation').element().getBoundingClientRect().left
|
||||
const filtersLeft = screen.getByTestId('catalog-filter').element().getBoundingClientRect().left
|
||||
|
||||
expect(tabsLeft).toBeCloseTo(contentLeft)
|
||||
expect(filtersLeft).toBeCloseTo(contentLeft)
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,10 @@ type MarketplaceFetchOptions = {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
// Matches backend warmup homepageCollectionPluginsRequests Limit: 20 so the
|
||||
// public POST hits the Redis bucket the scheduler already writes.
|
||||
export const COLLECTION_PREVIEW_PLUGIN_LIMIT = 20
|
||||
|
||||
type MarketplacePluginListExtras = {
|
||||
agent_strategy?: unknown
|
||||
data_sources?: unknown
|
||||
@@ -150,7 +154,7 @@ export const getMarketplacePluginsByCollectionId = async (
|
||||
params: {
|
||||
collectionId,
|
||||
},
|
||||
body: query ?? {},
|
||||
body: { limit: COLLECTION_PREVIEW_PLUGIN_LIMIT, ...query },
|
||||
},
|
||||
{
|
||||
signal: options?.signal,
|
||||
@@ -291,11 +295,12 @@ export function getCollectionsParams(
|
||||
category: ActivePluginType,
|
||||
): CollectionsAndPluginsSearchParams {
|
||||
if (category === PLUGIN_TYPE_SEARCH_MAP.all) {
|
||||
return {}
|
||||
return { limit: COLLECTION_PREVIEW_PLUGIN_LIMIT }
|
||||
}
|
||||
return {
|
||||
category,
|
||||
condition: getMarketplaceListCondition(category),
|
||||
type: getMarketplaceListFilterType(category),
|
||||
limit: COLLECTION_PREVIEW_PLUGIN_LIMIT,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,6 +287,7 @@ describe('AllStartBlocks', () => {
|
||||
createMarketplacePluginsMock([
|
||||
createPlugin({
|
||||
name: 'start-marketplace',
|
||||
plugin_id: 'plugin-2',
|
||||
label: { en_US: 'Start Marketplace', zh_Hans: 'Start Marketplace' },
|
||||
}),
|
||||
]),
|
||||
@@ -311,6 +312,28 @@ describe('AllStartBlocks', () => {
|
||||
expect(screen.getAllByRole('link', { name: /plugin\.searchInMarketplace/i })).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows installed triggers once and keeps other marketplace results available', async () => {
|
||||
enableMarketplaceForRender = true
|
||||
mockUseMarketplacePlugins.mockReturnValue(
|
||||
createMarketplacePluginsMock([
|
||||
createPlugin({ plugin_id: 'plugin-1', label: { en_US: 'Installed Marketplace' } }),
|
||||
createPlugin({ plugin_id: 'plugin-2', label: { en_US: 'Available Marketplace' } }),
|
||||
]),
|
||||
)
|
||||
|
||||
render(
|
||||
<AllStartBlocks
|
||||
searchText="provider"
|
||||
onSelect={vi.fn()}
|
||||
availableBlocksTypes={[BlockEnum.TriggerPlugin]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(await screen.findByText('Provider One')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Installed Marketplace')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Available Marketplace')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show the user input conflict state without allowing another start selection', () => {
|
||||
const onSelect = vi.fn()
|
||||
enableMarketplaceForRender = true
|
||||
|
||||
@@ -10,6 +10,7 @@ import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { Theme } from '@/types/app'
|
||||
import { BlockEnum } from '../../types'
|
||||
import DataSources from '../data-sources'
|
||||
import { createPlugin } from './factories'
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: vi.fn(),
|
||||
@@ -169,5 +170,40 @@ describe('DataSources', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('shows an installed source once and keeps other marketplace plugins available', async () => {
|
||||
enableMarketplaceForRender = true
|
||||
const provider = createToolProvider()
|
||||
mockUseMarketplacePlugins.mockReturnValue(
|
||||
createMarketplacePluginsMock({
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
plugins: [
|
||||
createPlugin({
|
||||
plugin_id: provider.plugin_id,
|
||||
label: { en_US: 'Installed Marketplace Source' },
|
||||
}),
|
||||
createPlugin({
|
||||
plugin_id: 'langgenius/other',
|
||||
label: { en_US: 'Other Marketplace Source' },
|
||||
}),
|
||||
],
|
||||
page: 1,
|
||||
page_size: 40,
|
||||
total: 2,
|
||||
},
|
||||
],
|
||||
pageParams: [1],
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
render(<DataSources searchText="file" onSelect={vi.fn()} dataSources={[provider]} />)
|
||||
|
||||
expect(screen.getByText('File Source')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Installed Marketplace Source')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Other Marketplace Source')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -117,9 +117,12 @@ function AllStartBlocks({
|
||||
)
|
||||
const { data: marketplacePluginsData, isFetching: isMarketplaceFetching } =
|
||||
useMarketplacePlugins(marketplaceSearchParams)
|
||||
const marketplacePlugins = useMemo(
|
||||
() => marketplacePluginsData?.pages.flatMap((page) => page.plugins) ?? [],
|
||||
[marketplacePluginsData?.pages],
|
||||
const notInstalledPlugins = useMemo(
|
||||
() =>
|
||||
marketplacePluginsData?.pages.flatMap((page) =>
|
||||
page.plugins.filter((plugin) => !providerMap.has(plugin.plugin_id)),
|
||||
) ?? [],
|
||||
[marketplacePluginsData?.pages, providerMap],
|
||||
)
|
||||
|
||||
const shouldShowFeatured = enableTriggerPlugin && enable_marketplace && !hasFilter
|
||||
@@ -136,7 +139,7 @@ function AllStartBlocks({
|
||||
|
||||
const hasInstalledPluginContent = enableTriggerPlugin && hasPluginContent
|
||||
const hasMarketplaceContent =
|
||||
enableTriggerPlugin && enable_marketplace && marketplacePlugins.length > 0
|
||||
enableTriggerPlugin && enable_marketplace && notInstalledPlugins.length > 0
|
||||
const hasAnyContent =
|
||||
hasStartBlocksContent ||
|
||||
hasInstalledPluginContent ||
|
||||
@@ -231,7 +234,7 @@ function AllStartBlocks({
|
||||
<PluginList
|
||||
ref={pluginRef}
|
||||
wrapElemRef={wrapElemRef as RefObject<HTMLElement>}
|
||||
list={marketplacePlugins}
|
||||
list={notInstalledPlugins}
|
||||
searchText={trimmedSearchText}
|
||||
category={PluginCategoryEnum.trigger}
|
||||
tags={tags}
|
||||
|
||||
@@ -106,10 +106,14 @@ function DataSources({
|
||||
],
|
||||
)
|
||||
const { data: marketplacePluginsData } = useMarketplacePlugins(marketplaceSearchParams)
|
||||
const notInstalledPlugins = useMemo(
|
||||
() => marketplacePluginsData?.pages.flatMap((page) => page.plugins) ?? [],
|
||||
[marketplacePluginsData?.pages],
|
||||
)
|
||||
const notInstalledPlugins = useMemo(() => {
|
||||
const installedPluginIds = new Set(dataSources.map((provider) => provider.plugin_id))
|
||||
return (
|
||||
marketplacePluginsData?.pages.flatMap((page) =>
|
||||
page.plugins.filter((plugin) => !installedPluginIds.has(plugin.plugin_id)),
|
||||
) ?? []
|
||||
)
|
||||
}, [dataSources, marketplacePluginsData?.pages])
|
||||
|
||||
return (
|
||||
<div className={cn('w-100 max-w-full min-w-0', className)}>
|
||||
|
||||
@@ -260,6 +260,25 @@ describe('AgentStrategySelector', () => {
|
||||
expect(screen.queryByText('beta')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('excludes installed strategy plugins from marketplace results', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.useMarketplacePlugins.mockReturnValue({
|
||||
queryPluginsWithDebounced: mocks.queryPluginsWithDebounced,
|
||||
plugins: [{ plugin_id: 'plugin-alpha' }, { plugin_id: 'market-agent' }],
|
||||
})
|
||||
|
||||
render(<AgentStrategySelector onChange={vi.fn()} />)
|
||||
await user.click(
|
||||
screen
|
||||
.getByText(/(?:^|\.)nodes\.agent\.strategy\.selectTip(?=$|:)/)
|
||||
.closest('[aria-haspopup]')!,
|
||||
)
|
||||
|
||||
expect(screen.getByText('alpha')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('plugin-list')).toHaveTextContent(':market-agent')
|
||||
expect(screen.getByTestId('plugin-list')).not.toHaveTextContent('plugin-alpha')
|
||||
})
|
||||
|
||||
it('maps the selected tool and closes the popover', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
@@ -147,8 +147,12 @@ export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) =>
|
||||
|
||||
const wrapElemRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const { queryPluginsWithDebounced: fetchPlugins, plugins: notInstalledPlugins = [] } =
|
||||
const { queryPluginsWithDebounced: fetchPlugins, plugins: marketplacePlugins = [] } =
|
||||
useMarketplacePlugins()
|
||||
const notInstalledPlugins = useMemo(() => {
|
||||
const installedPluginIds = new Set((stra.data ?? []).map((provider) => provider.plugin_id))
|
||||
return marketplacePlugins.filter((plugin) => !installedPluginIds.has(plugin.plugin_id))
|
||||
}, [marketplacePlugins, stra.data])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enable_marketplace) return
|
||||
|
||||
@@ -39,4 +39,17 @@ describe('agent file icon helpers', () => {
|
||||
expect(getFileIconType('archive.zip')).toBe('archive')
|
||||
expect(getFileIconType('script.ts')).toBe('code')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['references/notes.txt', undefined],
|
||||
['references/NOTES.TXT', undefined],
|
||||
['references/notes.txt', 'text/plain'],
|
||||
['references/notes.txt', 'application/octet-stream'],
|
||||
])('should identify %s as text regardless of MIME metadata (%s)', (fileName, mimeType) => {
|
||||
expect(getFileIconType(fileName, mimeType)).toBe('text')
|
||||
})
|
||||
|
||||
it('should keep unrecognized binary files as generic files', () => {
|
||||
expect(getFileIconType('data.bin', 'application/octet-stream')).toBe('file')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -63,7 +63,7 @@ export function getFileIconType(fileName: string, mimeType?: string | null): Fil
|
||||
if (tableFileExtensions.has(extension)) return 'table'
|
||||
if (archiveFileExtensions.has(extension)) return 'archive'
|
||||
if (codeFileExtensions.has(extension)) return 'code'
|
||||
if (mimeType?.startsWith('text/')) return 'text'
|
||||
if (extension === 'txt' || mimeType?.startsWith('text/')) return 'text'
|
||||
|
||||
return 'file'
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SkillDetailResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import {
|
||||
@@ -447,6 +447,213 @@ describe('SkillDetailPage builder', () => {
|
||||
expect(sendButton).toBeDisabled()
|
||||
}, 15000)
|
||||
|
||||
it('uses an available model instead of a default model that no longer exists', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.defaultTextGenerationModel = {
|
||||
provider: { provider: 'langgenius/openai/openai' },
|
||||
model: 'gpt-5-chat-latest',
|
||||
}
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
const promptInput = await screen.findByPlaceholderText(
|
||||
'skill.skillManagement.detail.builder.modifyPlaceholder',
|
||||
)
|
||||
await user.type(promptInput, 'Update the skill{Enter}')
|
||||
|
||||
expect(mocks.sendSkillAssistMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: expect.objectContaining({
|
||||
provider: 'langgenius/openai/openai',
|
||||
model: 'gpt-5.5',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(mocks.modelParameterRulesFetch).toHaveBeenCalled()
|
||||
const requestedModels = mocks.modelParameterRulesFetch.mock.calls.map(([input]) => {
|
||||
const url = input instanceof Request ? input.url : String(input)
|
||||
return new URL(url).searchParams.get('model')
|
||||
})
|
||||
expect(requestedModels).toEqual(['gpt-5.5'])
|
||||
})
|
||||
|
||||
it('does not request parameters or send with an unavailable default and no usable model', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.defaultTextGenerationModel = {
|
||||
provider: { provider: 'langgenius/openai/openai' },
|
||||
model: 'gpt-5-chat-latest',
|
||||
}
|
||||
mocks.textGenerationModelList = []
|
||||
mocks.skillDetail = createDefaultSkillDraftDetail()
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
const promptInput = await screen.findByPlaceholderText(
|
||||
'skill.skillManagement.detail.builder.placeholder',
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.builder.send',
|
||||
}),
|
||||
).toBeDisabled()
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.builder.exampleIssueTriage',
|
||||
}),
|
||||
).toBeDisabled()
|
||||
await user.type(promptInput, 'Create a skill{Enter}')
|
||||
|
||||
expect(mocks.sendSkillAssistMessage).not.toHaveBeenCalled()
|
||||
expect(mocks.modelParameterRulesFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps an available default model instead of replacing it with the first model', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.textGenerationModelList[0]!.models.unshift({
|
||||
model: 'gpt-4.1',
|
||||
status: 'active',
|
||||
})
|
||||
|
||||
renderSkillDetailPage()
|
||||
const promptInput = await screen.findByPlaceholderText(
|
||||
'skill.skillManagement.detail.builder.modifyPlaceholder',
|
||||
)
|
||||
await user.type(promptInput, 'Update the skill{Enter}')
|
||||
|
||||
expect(mocks.sendSkillAssistMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: expect.objectContaining({ model: 'gpt-5.5' }),
|
||||
}),
|
||||
)
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ target: 'provider', status: 'disabled' },
|
||||
{ target: 'model', status: 'disabled' },
|
||||
{ target: 'model', status: 'credential-removed' },
|
||||
])('does not use a default whose $target has status $status', async ({ target, status }) => {
|
||||
const user = userEvent.setup()
|
||||
const defaultProvider = mocks.textGenerationModelList[0]!
|
||||
if (target === 'provider') defaultProvider.status = status
|
||||
else defaultProvider.models[0]!.status = status
|
||||
mocks.textGenerationModelList.push({
|
||||
provider: 'langgenius/anthropic/anthropic',
|
||||
status: 'active',
|
||||
models: [{ model: 'claude-sonnet-4-6', status: 'active' }],
|
||||
})
|
||||
|
||||
renderSkillDetailPage()
|
||||
const promptInput = await screen.findByPlaceholderText(
|
||||
'skill.skillManagement.detail.builder.modifyPlaceholder',
|
||||
)
|
||||
await user.type(promptInput, 'Update the skill{Enter}')
|
||||
|
||||
expect(mocks.sendSkillAssistMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: expect.objectContaining({
|
||||
provider: 'langgenius/anthropic/anthropic',
|
||||
model: 'claude-sonnet-4-6',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(mocks.modelParameterRulesFetch).toHaveBeenCalledTimes(1)
|
||||
const request = mocks.modelParameterRulesFetch.mock.calls[0]![0]
|
||||
const url = new URL(request instanceof Request ? request.url : String(request))
|
||||
expect(url.pathname).toContain('/model-providers/langgenius/anthropic/anthropic/')
|
||||
expect(url.searchParams.get('model')).toBe('claude-sonnet-4-6')
|
||||
})
|
||||
|
||||
it('waits for available models before requesting parameters or allowing Builder sends', async () => {
|
||||
const user = userEvent.setup()
|
||||
let resolveModels: (response: { data: typeof mocks.textGenerationModelList }) => void = () => {}
|
||||
const modelListResponse = new Promise<{ data: typeof mocks.textGenerationModelList }>(
|
||||
(resolve) => {
|
||||
resolveModels = resolve
|
||||
},
|
||||
)
|
||||
mocks.textGenerationModelListGet.mockReturnValue(modelListResponse)
|
||||
mocks.skillDetail = createDefaultSkillDraftDetail()
|
||||
|
||||
renderSkillDetailPage()
|
||||
const promptInput = await screen.findByPlaceholderText(
|
||||
'skill.skillManagement.detail.builder.placeholder',
|
||||
)
|
||||
const sendButton = screen.getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.builder.send',
|
||||
})
|
||||
const suggestion = screen.getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.builder.exampleIssueTriage',
|
||||
})
|
||||
await user.type(promptInput, 'Create a skill{Enter}')
|
||||
|
||||
expect(sendButton).toBeDisabled()
|
||||
expect(suggestion).toBeDisabled()
|
||||
expect(mocks.sendSkillAssistMessage).not.toHaveBeenCalled()
|
||||
expect(mocks.modelParameterRulesFetch).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
resolveModels({ data: mocks.textGenerationModelList })
|
||||
await modelListResponse
|
||||
})
|
||||
await waitFor(() => expect(sendButton).toBeEnabled())
|
||||
expect(suggestion).toBeEnabled()
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(mocks.sendSkillAssistMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ model: expect.objectContaining({ model: 'gpt-5.5' }) }),
|
||||
)
|
||||
expect(mocks.modelParameterRulesFetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('preserves user-edited parameters for an available Builder model', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.modelParameterRulesFetch.mockImplementation(async () =>
|
||||
Response.json({
|
||||
data: [
|
||||
{
|
||||
name: 'temperature',
|
||||
label: { en_US: 'Temperature' },
|
||||
help: { en_US: 'Control randomness' },
|
||||
type: 'float',
|
||||
min: 0,
|
||||
max: 2,
|
||||
default: 0.7,
|
||||
precision: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
renderSkillDetailPage()
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'common.modelProvider.modelSettings',
|
||||
}),
|
||||
)
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: 'common.modelProvider.modelSettings',
|
||||
})
|
||||
const temperature = await within(dialog).findByRole('spinbutton', { name: 'Temperature' })
|
||||
await user.clear(temperature)
|
||||
await user.type(temperature, '1.2')
|
||||
await user.click(within(dialog).getByRole('button', { name: 'common.operation.close' }))
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('skill.skillManagement.detail.builder.modifyPlaceholder'),
|
||||
'Update the skill{Enter}',
|
||||
)
|
||||
|
||||
expect(mocks.sendSkillAssistMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: {
|
||||
provider: 'langgenius/openai/openai',
|
||||
model: 'gpt-5.5',
|
||||
model_settings: { temperature: 1.2 },
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('uploads image attachments in Skill Builder', async () => {
|
||||
const user = userEvent.setup({ applyAccept: false })
|
||||
mocks.uploadSkillFile.mockResolvedValue({
|
||||
|
||||
@@ -41,7 +41,7 @@ describe('SkillDetailPage metadata', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('saves selected workspace tags when the selector closes', async () => {
|
||||
it('previews selected workspace tags immediately and saves once when the selector closes', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillDetailPage()
|
||||
|
||||
@@ -51,6 +51,21 @@ describe('SkillDetailPage metadata', () => {
|
||||
}),
|
||||
)
|
||||
await user.click(await screen.findByRole('option', { name: 'Search' }))
|
||||
|
||||
expect(
|
||||
within(screen.getByRole('region', { name: /skillManagement\.detail\.fileCount/ })).getByText(
|
||||
'Search',
|
||||
),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('listbox')).toBeVisible()
|
||||
expect(screen.getByRole('option', { name: 'Search' })).toHaveAttribute('aria-selected', 'true')
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.removeTag:{"tag":"Search"}',
|
||||
}),
|
||||
).toBeDisabled()
|
||||
expect(mocks.skillMetadataMutationFn).not.toHaveBeenCalled()
|
||||
|
||||
await user.click(screen.getByTestId('skill-detail-sidebar-header'))
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -64,9 +79,10 @@ describe('SkillDetailPage metadata', () => {
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
expect(mocks.skillMetadataMutationFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('removes a selected tag when it is unchecked and the selector closes', async () => {
|
||||
it('previews unchecked tags immediately and saves once when the selector closes', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createSkillDetail({
|
||||
tags: ['Search', 'Productivity'],
|
||||
@@ -79,6 +95,16 @@ describe('SkillDetailPage metadata', () => {
|
||||
}),
|
||||
)
|
||||
await user.click(await screen.findByRole('option', { name: 'Search' }))
|
||||
|
||||
const sidebar = within(
|
||||
screen.getByRole('region', { name: /skillManagement\.detail\.fileCount/ }),
|
||||
)
|
||||
expect(sidebar.queryByText('Search')).not.toBeInTheDocument()
|
||||
expect(sidebar.getByText('Productivity')).toBeInTheDocument()
|
||||
expect(screen.getByRole('listbox')).toBeVisible()
|
||||
expect(screen.getByRole('option', { name: 'Search' })).toHaveAttribute('aria-selected', 'false')
|
||||
expect(mocks.skillMetadataMutationFn).not.toHaveBeenCalled()
|
||||
|
||||
await user.click(screen.getByTestId('skill-detail-sidebar-header'))
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -91,6 +117,7 @@ describe('SkillDetailPage metadata', () => {
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
expect(mocks.skillMetadataMutationFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('renders an unmatched search as a create action instead of a tag checkbox', async () => {
|
||||
@@ -117,7 +144,7 @@ describe('SkillDetailPage metadata', () => {
|
||||
expect(screen.queryByRole('option', { name: 'BrandNew' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('creates and binds an unmatched tag when the create action is selected', async () => {
|
||||
it('previews a newly created tag immediately and binds it when the selector closes', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillDetailPage()
|
||||
|
||||
@@ -137,6 +164,19 @@ describe('SkillDetailPage metadata', () => {
|
||||
name: "common.tag.create 'BrandNew'",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(
|
||||
within(screen.getByRole('region', { name: /skillManagement\.detail\.fileCount/ })).getByText(
|
||||
'BrandNew',
|
||||
),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('listbox')).toBeVisible()
|
||||
expect(screen.getByRole('option', { name: 'BrandNew' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
)
|
||||
expect(mocks.skillMetadataMutationFn).not.toHaveBeenCalled()
|
||||
|
||||
await user.click(screen.getByTestId('skill-detail-sidebar-header'))
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -149,9 +189,10 @@ describe('SkillDetailPage metadata', () => {
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
expect(mocks.skillMetadataMutationFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows an added tag only after the metadata request finishes', async () => {
|
||||
it('keeps selected tags visible and prevents reopening while the metadata request is pending', async () => {
|
||||
const user = userEvent.setup()
|
||||
let resolveMutation: ((detail: SkillDetailResponse) => void) | undefined
|
||||
mocks.skillDetailKey.mockReturnValue(['skill-detail'])
|
||||
@@ -175,7 +216,23 @@ describe('SkillDetailPage metadata', () => {
|
||||
await user.click(await screen.findByRole('option', { name: 'Search' }))
|
||||
await user.click(screen.getByTestId('skill-detail-sidebar-header'))
|
||||
|
||||
expect(screen.queryByText('Search')).not.toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByRole('region', { name: /skillManagement\.detail\.fileCount/ })).getByText(
|
||||
'Search',
|
||||
),
|
||||
).toBeInTheDocument()
|
||||
const addTagButton = screen.getByRole('combobox', {
|
||||
name: 'skill.skillManagement.detail.addTag',
|
||||
})
|
||||
expect(addTagButton).toBeDisabled()
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.removeTag:{"tag":"Search"}',
|
||||
}),
|
||||
).toBeDisabled()
|
||||
await user.click(addTagButton)
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
||||
expect(mocks.skillMetadataMutationFn).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => {
|
||||
const nextDetail = createSkillDetail({
|
||||
@@ -187,6 +244,61 @@ describe('SkillDetailPage metadata', () => {
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Search')).toBeInTheDocument()
|
||||
expect(addTagButton).toBeEnabled()
|
||||
})
|
||||
|
||||
it('restores persisted tags when saving the visible selection fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
let rejectMutation: ((error: Error) => void) | undefined
|
||||
mocks.skillDetailKey.mockReturnValue(['skill-detail'])
|
||||
mocks.skillDetailQueryOptions.mockImplementation(() => ({
|
||||
queryKey: ['skill-detail'],
|
||||
queryFn: async () => mocks.skillDetail,
|
||||
}))
|
||||
mocks.skillDetail = createSkillDetail({ tags: ['Search'] })
|
||||
mocks.skillMetadataMutationFn.mockImplementation(
|
||||
() =>
|
||||
new Promise<SkillDetailResponse>((_resolve, reject) => {
|
||||
rejectMutation = reject
|
||||
}),
|
||||
)
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('combobox', {
|
||||
name: 'skill.skillManagement.detail.addTag',
|
||||
}),
|
||||
)
|
||||
await user.click(await screen.findByRole('option', { name: 'Search' }))
|
||||
await user.click(screen.getByRole('option', { name: 'Productivity' }))
|
||||
await user.click(screen.getByTestId('skill-detail-sidebar-header'))
|
||||
|
||||
const sidebar = within(
|
||||
screen.getByRole('region', { name: /skillManagement\.detail\.fileCount/ }),
|
||||
)
|
||||
expect(sidebar.queryByText('Search')).not.toBeInTheDocument()
|
||||
expect(sidebar.getByText('Productivity')).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
rejectMutation?.(new Error('Saving tags failed'))
|
||||
})
|
||||
|
||||
expect(await sidebar.findByText('Search')).toBeInTheDocument()
|
||||
expect(sidebar.queryByText('Productivity')).not.toBeInTheDocument()
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('skill.skillManagement.detail.updateTagsFailed')
|
||||
await user.click(
|
||||
screen.getByRole('combobox', {
|
||||
name: 'skill.skillManagement.detail.addTag',
|
||||
}),
|
||||
)
|
||||
expect(await screen.findByRole('option', { name: 'Search' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
)
|
||||
expect(screen.getByRole('option', { name: 'Productivity' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'false',
|
||||
)
|
||||
})
|
||||
|
||||
it('opens tag management from the selector', async () => {
|
||||
@@ -341,31 +453,62 @@ describe('SkillDetailPage metadata', () => {
|
||||
expect(renameInput).toHaveValue('Untitled skill')
|
||||
})
|
||||
|
||||
it('opens the returned duplicate for inline rename from the sidebar More menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.duplicateSkillMutationFn.mockResolvedValue(createSkillDetail({ id: 'copied-skill' }))
|
||||
renderSkillDetailPage()
|
||||
|
||||
const moreButton = await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.moreActions:{"name":"Untitled skill"}',
|
||||
})
|
||||
await user.click(moreButton)
|
||||
await user.click(screen.getByRole('menuitem', { name: 'common.operation.duplicate' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.duplicateSkillMutationFn).toHaveBeenCalledWith(
|
||||
{ params: { skill_id: 'skill-1' } },
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('skill.skillManagement.duplicateSuccess')
|
||||
expect(mocks.routerPush).toHaveBeenCalledWith('/skills/copied-skill?rename=true')
|
||||
})
|
||||
|
||||
it('stays on the original detail when duplication fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.duplicateSkillMutationFn.mockRejectedValue(new Error('Duplicate failed'))
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.moreActions:{"name":"Untitled skill"}',
|
||||
}),
|
||||
)
|
||||
await user.click(screen.getByRole('menuitem', { name: 'common.operation.duplicate' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('skill.skillManagement.duplicateFailed')
|
||||
})
|
||||
expect(mocks.routerPush).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.rename' })).toHaveTextContent(
|
||||
'Untitled skill',
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['version-1', null])(
|
||||
'duplicates and exports the current skill with published version %s from the sidebar More menu',
|
||||
'exports the current skill with published version %s from the sidebar More menu',
|
||||
async (publishedVersionId) => {
|
||||
const user = userEvent.setup()
|
||||
const archive = new Blob(['archive'], { type: 'application/zip' })
|
||||
mocks.skillDetail = createSkillDetail({ latest_published_version_id: publishedVersionId })
|
||||
mocks.duplicateSkillMutationFn.mockResolvedValue({})
|
||||
mocks.fetchSkillArchiveBlob.mockResolvedValue(archive)
|
||||
mocks.skillDetail = createSkillDetail({ latest_published_version_id: publishedVersionId })
|
||||
renderSkillDetailPage()
|
||||
|
||||
const moreButton = await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.moreActions:{"name":"Untitled skill"}',
|
||||
})
|
||||
await user.click(moreButton)
|
||||
await user.click(screen.getByRole('menuitem', { name: 'common.operation.duplicate' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.duplicateSkillMutationFn).toHaveBeenCalledWith(
|
||||
{ params: { skill_id: 'skill-1' } },
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('skill.skillManagement.duplicateSuccess')
|
||||
|
||||
await user.click(moreButton)
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.moreActions:{"name":"Untitled skill"}',
|
||||
}),
|
||||
)
|
||||
await user.click(screen.getByRole('menuitem', { name: 'common.operation.export' }))
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -378,6 +521,84 @@ describe('SkillDetailPage metadata', () => {
|
||||
},
|
||||
)
|
||||
|
||||
it('does not start inline rename on an ordinary detail visit', async () => {
|
||||
const { onUrlUpdate } = renderSkillDetailPage()
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', { name: 'common.operation.rename' }),
|
||||
).toHaveTextContent('Untitled skill')
|
||||
expect(
|
||||
screen.queryByRole('textbox', { name: 'common.operation.rename' }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(onUrlUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('starts inline rename after the copied detail loads and consumes the request once', async () => {
|
||||
const user = userEvent.setup()
|
||||
let resolveDetail: (detail: SkillDetailResponse) => void = () => {}
|
||||
const detailPromise = new Promise<SkillDetailResponse>((resolve) => {
|
||||
resolveDetail = resolve
|
||||
})
|
||||
mocks.skillDetailQueryOptions.mockImplementation((options) => ({
|
||||
queryKey: ['skill-detail', options],
|
||||
queryFn: () => detailPromise,
|
||||
}))
|
||||
const { onUrlUpdate, queryClient } = renderSkillDetailPage({
|
||||
searchParams: '?rename=true&source=list',
|
||||
strict: true,
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.queryByRole('textbox', { name: 'common.operation.rename' }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(onUrlUpdate).not.toHaveBeenCalled()
|
||||
await act(async () => resolveDetail(createSkillDetail()))
|
||||
|
||||
const renameInput = await screen.findByRole('textbox', { name: 'common.operation.rename' })
|
||||
expect(renameInput).toHaveFocus()
|
||||
expect(renameInput).toHaveValue('Untitled skill')
|
||||
expect(renameInput).toHaveProperty('selectionStart', 0)
|
||||
expect(renameInput).toHaveProperty('selectionEnd', 'Untitled skill'.length)
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
expect(onUrlUpdate.mock.lastCall?.[0].searchParams.has('rename')).toBe(false)
|
||||
expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get('source')).toBe('list')
|
||||
|
||||
await user.keyboard('{Escape}')
|
||||
expect(screen.getByRole('button', { name: 'common.operation.rename' })).toBeInTheDocument()
|
||||
await act(async () => queryClient.invalidateQueries())
|
||||
expect(
|
||||
screen.queryByRole('textbox', { name: 'common.operation.rename' }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(mocks.skillMetadataMutationFn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('saves the automatically activated name editor without activating it again', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onUrlUpdate, queryClient } = renderSkillDetailPage({ searchParams: '?rename=true' })
|
||||
const renameInput = await screen.findByRole('textbox', { name: 'common.operation.rename' })
|
||||
|
||||
await user.clear(renameInput)
|
||||
await user.type(renameInput, 'Renamed duplicate{Enter}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'common.operation.rename' })).toHaveTextContent(
|
||||
'Renamed duplicate',
|
||||
)
|
||||
})
|
||||
expect(mocks.skillMetadataMutationFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({ display_name: 'Renamed duplicate' }),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
expect(onUrlUpdate.mock.lastCall?.[0].searchParams.has('rename')).toBe(false)
|
||||
await act(async () => queryClient.invalidateQueries())
|
||||
expect(
|
||||
screen.queryByRole('textbox', { name: 'common.operation.rename' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('requires the display name before deleting a referenced skill from the sidebar', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createSkillDetail({ reference_count: 1 })
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Dialog, DialogPopup, DialogPortal, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it } from 'vite-plus/test'
|
||||
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
||||
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
|
||||
import {
|
||||
createFileTabSkillDetail,
|
||||
createReferencePickerSkillDetail,
|
||||
createSkillDetail,
|
||||
createSkillVersion,
|
||||
getFileTabButton,
|
||||
getFileTreeButton,
|
||||
getLiveMarkdownEditor,
|
||||
@@ -78,6 +81,61 @@ describe('SkillDetailPage navigation', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens another skill in its editable draft without retaining the original view state', async () => {
|
||||
const { default: SkillDetailPageRoute } = await import('../detail-page')
|
||||
const user = userEvent.setup()
|
||||
const version = createSkillVersion({ id: 'original-version', version_name: 'Original version' })
|
||||
mocks.skillVersionsQueryOptions.mockImplementation((options) => ({
|
||||
queryKey: ['skill-versions', options],
|
||||
queryFn: async () => ({ data: [version] }),
|
||||
}))
|
||||
mocks.skillVersionDetailQueryOptions.mockImplementation((options) => ({
|
||||
queryKey: ['skill-version-detail', options],
|
||||
queryFn: async () => ({
|
||||
...version,
|
||||
files: createSkillDetail().files,
|
||||
}),
|
||||
}))
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
const { wrapper } = createNuqsTestWrapper()
|
||||
const { rerender } = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SkillDetailPageRoute />
|
||||
</QueryClientProvider>,
|
||||
{ wrapper },
|
||||
)
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: 'skill.skillManagement.detail.versionHistory' }),
|
||||
)
|
||||
await user.click(await screen.findByRole('button', { name: /Original version/ }))
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'common.operation.rename' }),
|
||||
).not.toBeInTheDocument()
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'skill.skillManagement.detail.collapseSidebar' }),
|
||||
)
|
||||
expect(screen.queryByTestId('skill-detail-sidebar-header')).not.toBeInTheDocument()
|
||||
|
||||
mocks.routeSkillId = 'copied-skill'
|
||||
mocks.skillDetail = createSkillDetail({ id: 'copied-skill', display_name: 'Copied skill' })
|
||||
rerender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SkillDetailPageRoute />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(await screen.findByTestId('skill-detail-sidebar-header')).toBeInTheDocument()
|
||||
expect(
|
||||
await screen.findByRole('button', { name: 'common.operation.rename' }),
|
||||
).toHaveTextContent('Copied skill')
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'skill.skillManagement.detail.restoreVersion' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the sidebar while the collapsed rail is hovered', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillDetailPage()
|
||||
|
||||
@@ -11,7 +11,8 @@ import { detectPlatform } from '@tanstack/react-hotkeys'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { StrictMode } from 'react'
|
||||
import { vi } from 'vite-plus/test'
|
||||
import { afterEach, vi } from 'vite-plus/test'
|
||||
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
|
||||
import { SkillDetailPage } from '../detail/page'
|
||||
|
||||
export const primaryModifier = detectPlatform() === 'mac' ? { metaKey: true } : { ctrlKey: true }
|
||||
@@ -27,9 +28,12 @@ const mocks = vi.hoisted(() => ({
|
||||
publishSkillMutationFn: vi.fn(),
|
||||
publishSkillMutationOptions: vi.fn(),
|
||||
routerPush: vi.fn(),
|
||||
routeSkillId: 'skill-1',
|
||||
restoreSkillMutationFn: vi.fn(),
|
||||
saveDraftFileMutationFn: vi.fn(),
|
||||
sendSkillAssistMessage: vi.fn(),
|
||||
modelParameterRulesFetch: vi.fn<typeof fetch>(),
|
||||
textGenerationModelListGet: vi.fn(),
|
||||
defaultTextGenerationModel: undefined as
|
||||
| { provider: { provider: string }; model: string }
|
||||
| undefined,
|
||||
@@ -112,6 +116,7 @@ vi.mock('@/app/components/main-nav/components/help-menu', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useLanguage: () => 'en_US',
|
||||
useDefaultModel: () => ({
|
||||
data: mocks.defaultTextGenerationModel,
|
||||
}),
|
||||
@@ -127,14 +132,6 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-selec
|
||||
SplitModelSelector: () => <button type="button">model-settings</button>,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/service/use-common')>()),
|
||||
useModelParameterRules: () => ({
|
||||
data: { data: [] },
|
||||
isLoading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
|
||||
default: ({ onChange, value }: { onChange?: (value: string) => void; value: string }) => (
|
||||
<textarea
|
||||
@@ -175,6 +172,7 @@ vi.mock('@/next/link', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useParams: () => ({ skillId: mocks.routeSkillId }),
|
||||
useRouter: () => ({
|
||||
push: mocks.routerPush,
|
||||
}),
|
||||
@@ -489,11 +487,15 @@ export function renderSkillDetailPage({
|
||||
mutations: { retry: false },
|
||||
},
|
||||
}),
|
||||
searchParams = '',
|
||||
strict = false,
|
||||
}: {
|
||||
queryClient?: QueryClient
|
||||
searchParams?: string
|
||||
strict?: boolean
|
||||
} = {}) {
|
||||
const { wrapper, onUrlUpdate } = createNuqsTestWrapper({ searchParams })
|
||||
|
||||
return {
|
||||
...render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -505,7 +507,9 @@ export function renderSkillDetailPage({
|
||||
<SkillDetailPage skillId="skill-1" />
|
||||
)}
|
||||
</QueryClientProvider>,
|
||||
{ wrapper },
|
||||
),
|
||||
onUrlUpdate,
|
||||
queryClient,
|
||||
}
|
||||
}
|
||||
@@ -676,6 +680,14 @@ export function getMocks() {
|
||||
export function resetDetailPageFixture() {
|
||||
vi.useRealTimers()
|
||||
vi.resetAllMocks()
|
||||
mocks.modelParameterRulesFetch.mockImplementation(async (input) => {
|
||||
const url = input instanceof Request ? input.url : String(input)
|
||||
if (!url.includes('/models/parameter-rules'))
|
||||
throw new Error(`Unexpected Skill detail request: ${url}`)
|
||||
return Response.json({ data: [] })
|
||||
})
|
||||
vi.stubGlobal('fetch', mocks.modelParameterRulesFetch)
|
||||
mocks.routeSkillId = 'skill-1'
|
||||
mocks.defaultTextGenerationModel = {
|
||||
provider: {
|
||||
provider: 'langgenius/openai/openai',
|
||||
@@ -694,6 +706,9 @@ export function resetDetailPageFixture() {
|
||||
],
|
||||
},
|
||||
]
|
||||
mocks.textGenerationModelListGet.mockImplementation(async () => ({
|
||||
data: mocks.textGenerationModelList,
|
||||
}))
|
||||
mocks.skillDetail = createSkillDetail()
|
||||
mocks.skillDetailGetFn.mockImplementation(async () => mocks.skillDetail)
|
||||
mocks.skillDetailKey.mockImplementation((options) => ['skill-detail', options])
|
||||
@@ -845,6 +860,10 @@ export function resetDetailPageFixture() {
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
|
||||
return {
|
||||
@@ -856,7 +875,7 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
>
|
||||
}) =>
|
||||
options.queryKey[0].includes('modelTypes')
|
||||
? { data: mocks.textGenerationModelList, isPending: false }
|
||||
? actual.useQuery({ ...options, queryFn: mocks.textGenerationModelListGet })
|
||||
: actual.useQuery(options),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -251,6 +251,14 @@ function renderSkillsPage(queryClient = createTestQueryClient()) {
|
||||
)
|
||||
}
|
||||
|
||||
async function openImportDialog(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(await screen.findByRole('button', { name: 'skill.skillManagement.import' }))
|
||||
|
||||
return screen.findByRole('dialog', {
|
||||
name: 'skill.skillManagement.importDialog.title',
|
||||
})
|
||||
}
|
||||
|
||||
describe('SkillsPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -769,35 +777,200 @@ describe('SkillsPage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('imports a package file and navigates to the imported skill', async () => {
|
||||
const user = userEvent.setup()
|
||||
const invalidateQueries = vi.spyOn(QueryClient.prototype, 'invalidateQueries')
|
||||
const { container } = renderSkillsPage()
|
||||
it.each(['toolbar', 'empty state'])(
|
||||
'shows package requirements from the %s import entry',
|
||||
async (entry) => {
|
||||
const user = userEvent.setup()
|
||||
if (entry === 'empty state') mocks.skillPages = [[]]
|
||||
renderSkillsPage()
|
||||
|
||||
const fileInput = container.querySelector<HTMLInputElement>('input[type="file"]')
|
||||
expect(fileInput).not.toBeNull()
|
||||
const file = new File(['skill'], 'refund.skill', { type: 'application/zip' })
|
||||
|
||||
await user.upload(fileInput!, file)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.importSkillMutationFn).toHaveBeenCalledWith(
|
||||
{
|
||||
body: {
|
||||
file,
|
||||
},
|
||||
},
|
||||
expect.anything(),
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name:
|
||||
entry === 'toolbar'
|
||||
? 'skill.skillManagement.import'
|
||||
: /skill\.skillManagement\.emptyAction\.importTitle/,
|
||||
}),
|
||||
)
|
||||
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: 'skill.skillManagement.importDialog.title',
|
||||
})
|
||||
expect(dialog).toHaveAccessibleDescription('skill.skillManagement.importDialog.description')
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: 'skill.skillManagement.import' }),
|
||||
).toBeDisabled()
|
||||
expect(mocks.importSkillMutationFn).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['refund.skill', 'refund.zip'])(
|
||||
'stages %s until confirmation, then navigates to the imported skill',
|
||||
async (fileName) => {
|
||||
const user = userEvent.setup()
|
||||
const invalidateQueries = vi.spyOn(QueryClient.prototype, 'invalidateQueries')
|
||||
renderSkillsPage()
|
||||
|
||||
const dialog = await openImportDialog(user)
|
||||
const file = new File(['skill'], fileName, { type: 'application/zip' })
|
||||
|
||||
await user.upload(
|
||||
within(dialog).getByLabelText('skill.skillManagement.importDialog.browse'),
|
||||
file,
|
||||
)
|
||||
expect(within(dialog).getByText(fileName)).toBeInTheDocument()
|
||||
expect(mocks.importSkillMutationFn).not.toHaveBeenCalled()
|
||||
await user.click(within(dialog).getByRole('button', { name: 'skill.skillManagement.import' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.importSkillMutationFn).toHaveBeenCalledWith(
|
||||
{
|
||||
body: {
|
||||
file,
|
||||
},
|
||||
},
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
expect(toast.success).toHaveBeenCalledWith('skill.skillManagement.importSuccess')
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['skills', { type: 'query' }] })
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['skills', { type: 'infinite' }] })
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['skill-tags', { type: 'query' }],
|
||||
})
|
||||
expect(mocks.push).toHaveBeenCalledWith('/skills/imported-skill')
|
||||
invalidateQueries.mockRestore()
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['common.operation.cancel', 'common.operation.close'])(
|
||||
'clears the staged package when dismissed with %s and reopened',
|
||||
async (closeAction) => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillsPage()
|
||||
const dialog = await openImportDialog(user)
|
||||
await user.upload(
|
||||
within(dialog).getByLabelText('skill.skillManagement.importDialog.browse'),
|
||||
new File(['skill'], 'refund.skill', { type: 'application/zip' }),
|
||||
)
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: closeAction }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
const reopenedDialog = await openImportDialog(user)
|
||||
|
||||
expect(within(reopenedDialog).queryByText('refund.skill')).not.toBeInTheDocument()
|
||||
expect(
|
||||
within(reopenedDialog).getByRole('button', { name: 'skill.skillManagement.import' }),
|
||||
).toBeDisabled()
|
||||
expect(mocks.importSkillMutationFn).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
it('removes a staged package and allows selecting that same file again', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillsPage()
|
||||
const dialog = await openImportDialog(user)
|
||||
const file = new File(['skill'], 'refund.skill', { type: 'application/zip' })
|
||||
const fileInput = within(dialog).getByLabelText('skill.skillManagement.importDialog.browse')
|
||||
await user.upload(fileInput, file)
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: 'common.operation.remove' }))
|
||||
expect(within(dialog).queryByText('refund.skill')).not.toBeInTheDocument()
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: 'skill.skillManagement.import' }),
|
||||
).toBeDisabled()
|
||||
await user.upload(fileInput, file)
|
||||
|
||||
expect(within(dialog).getByText('refund.skill')).toBeInTheDocument()
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: 'skill.skillManagement.import' }),
|
||||
).toBeEnabled()
|
||||
expect(mocks.importSkillMutationFn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['an unsupported extension', ['refund.txt']],
|
||||
['multiple packages', ['refund.skill', 'support.zip']],
|
||||
])('rejects dropping %s and allows correction before importing', async (_label, fileNames) => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillsPage()
|
||||
const dialog = await openImportDialog(user)
|
||||
const dropZone = within(dialog).getByRole('group', {
|
||||
name: 'skill.skillManagement.importDialog.title',
|
||||
})
|
||||
expect(toast.success).toHaveBeenCalledWith('skill.skillManagement.importSuccess')
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['skills', { type: 'query' }] })
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['skills', { type: 'infinite' }] })
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['skill-tags', { type: 'query' }],
|
||||
|
||||
fireEvent.drop(dropZone, {
|
||||
dataTransfer: {
|
||||
files: fileNames.map((fileName) => new File(['skill'], fileName)),
|
||||
types: ['Files'],
|
||||
},
|
||||
})
|
||||
expect(mocks.push).toHaveBeenCalledWith('/skills/imported-skill')
|
||||
invalidateQueries.mockRestore()
|
||||
expect(within(dialog).getByRole('alert')).toHaveTextContent(
|
||||
'skill.skillManagement.importDialog.invalidFile',
|
||||
)
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: 'skill.skillManagement.import' }),
|
||||
).toBeDisabled()
|
||||
expect(mocks.importSkillMutationFn).not.toHaveBeenCalled()
|
||||
|
||||
const file = new File(['skill'], 'refund.zip', { type: 'application/zip' })
|
||||
fireEvent.drop(dropZone, { dataTransfer: { files: [file], types: ['Files'] } })
|
||||
expect(within(dialog).queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(within(dialog).getByText('refund.zip')).toBeInTheDocument()
|
||||
expect(mocks.importSkillMutationFn).not.toHaveBeenCalled()
|
||||
await user.click(within(dialog).getByRole('button', { name: 'skill.skillManagement.import' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.importSkillMutationFn).toHaveBeenCalledWith(
|
||||
{ body: { file } },
|
||||
expect.anything(),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the package stable and prevents repeat submission or dismissal while importing', async () => {
|
||||
const user = userEvent.setup()
|
||||
let completeImport: ((skill: SkillResponse) => void) | undefined
|
||||
mocks.importSkillMutationFn.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<SkillResponse>((resolve) => {
|
||||
completeImport = resolve
|
||||
}),
|
||||
)
|
||||
renderSkillsPage()
|
||||
const dialog = await openImportDialog(user)
|
||||
const fileInput = within(dialog).getByLabelText('skill.skillManagement.importDialog.browse')
|
||||
const file = new File(['skill'], 'refund.skill', { type: 'application/zip' })
|
||||
await user.upload(fileInput, file)
|
||||
const importButton = within(dialog).getByRole('button', {
|
||||
name: 'skill.skillManagement.import',
|
||||
})
|
||||
await user.click(importButton)
|
||||
|
||||
await waitFor(() => expect(importButton).toHaveAttribute('aria-disabled', 'true'))
|
||||
expect(fileInput).toBeDisabled()
|
||||
const cancelButton = within(dialog).getByRole('button', { name: 'common.operation.cancel' })
|
||||
const closeButton = within(dialog).getByRole('button', { name: 'common.operation.close' })
|
||||
expect(cancelButton).toBeDisabled()
|
||||
expect(closeButton).toBeDisabled()
|
||||
expect(within(dialog).getByRole('button', { name: 'common.operation.remove' })).toBeDisabled()
|
||||
await user.click(importButton)
|
||||
await user.click(cancelButton)
|
||||
await user.click(closeButton)
|
||||
await user.keyboard('{Escape}')
|
||||
fireEvent.drop(
|
||||
within(dialog).getByRole('group', { name: 'skill.skillManagement.importDialog.title' }),
|
||||
{
|
||||
dataTransfer: { files: [new File(['other'], 'replacement.zip')], types: ['Files'] },
|
||||
},
|
||||
)
|
||||
|
||||
expect(dialog).toBeInTheDocument()
|
||||
expect(within(dialog).getByText('refund.skill')).toBeInTheDocument()
|
||||
expect(within(dialog).queryByText('replacement.zip')).not.toBeInTheDocument()
|
||||
expect(mocks.importSkillMutationFn).toHaveBeenCalledTimes(1)
|
||||
completeImport?.(createSkill({ id: 'imported-skill' }))
|
||||
await waitFor(() => expect(mocks.push).toHaveBeenCalledWith('/skills/imported-skill'))
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -831,35 +1004,54 @@ describe('SkillsPage', () => {
|
||||
])('explains import errors for $error.code', async ({ error, message }) => {
|
||||
const user = userEvent.setup()
|
||||
mocks.importSkillMutationFn.mockRejectedValueOnce(error)
|
||||
const { container } = renderSkillsPage()
|
||||
const fileInput = container.querySelector<HTMLInputElement>('input[type="file"]')
|
||||
renderSkillsPage()
|
||||
const dialog = await openImportDialog(user)
|
||||
const file = new File(['skill'], 'refund.skill', { type: 'application/zip' })
|
||||
|
||||
await user.upload(fileInput!, file)
|
||||
await user.upload(
|
||||
within(dialog).getByLabelText('skill.skillManagement.importDialog.browse'),
|
||||
file,
|
||||
)
|
||||
await user.click(within(dialog).getByRole('button', { name: 'skill.skillManagement.import' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(message)
|
||||
})
|
||||
})
|
||||
|
||||
it('explains an import error returned as a Response body', async () => {
|
||||
it('keeps the package available for retry after explaining an error returned as a Response body', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.importSkillMutationFn.mockRejectedValueOnce(
|
||||
new Response(JSON.stringify({ message: 'Skill package must contain SKILL.md' }), {
|
||||
status: 400,
|
||||
}),
|
||||
)
|
||||
const { container } = renderSkillsPage()
|
||||
const fileInput = container.querySelector<HTMLInputElement>('input[type="file"]')
|
||||
renderSkillsPage()
|
||||
const dialog = await openImportDialog(user)
|
||||
const file = new File(['skill'], 'refund.skill', { type: 'application/zip' })
|
||||
|
||||
await user.upload(fileInput!, new File(['skill'], 'refund.skill', { type: 'application/zip' }))
|
||||
await user.upload(
|
||||
within(dialog).getByLabelText('skill.skillManagement.importDialog.browse'),
|
||||
file,
|
||||
)
|
||||
await user.click(within(dialog).getByRole('button', { name: 'skill.skillManagement.import' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('skill.skillManagement.errors.missingSkillMd')
|
||||
})
|
||||
expect(within(dialog).getByText('refund.skill')).toBeInTheDocument()
|
||||
expect(mocks.push).not.toHaveBeenCalled()
|
||||
await user.click(within(dialog).getByRole('button', { name: 'skill.skillManagement.import' }))
|
||||
|
||||
await waitFor(() => expect(mocks.push).toHaveBeenCalledWith('/skills/imported-skill'))
|
||||
expect(mocks.importSkillMutationFn).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.importSkillMutationFn).toHaveBeenLastCalledWith(
|
||||
{ body: { file } },
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
it('duplicates a skill from the card action menu', async () => {
|
||||
it('opens the returned duplicate for inline rename from the card action menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillsPage()
|
||||
|
||||
@@ -881,6 +1073,25 @@ describe('SkillsPage', () => {
|
||||
)
|
||||
})
|
||||
expect(toast.success).toHaveBeenCalledWith('skill.skillManagement.duplicateSuccess')
|
||||
expect(mocks.push).toHaveBeenCalledWith('/skills/duplicated-skill?rename=true')
|
||||
})
|
||||
|
||||
it('stays on the skill list when duplication fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.duplicateSkillMutationFn.mockRejectedValue(new Error('Duplicate failed'))
|
||||
renderSkillsPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.moreActions:{"name":"Refund approval"}',
|
||||
}),
|
||||
)
|
||||
await user.click(await screen.findByText('common.operation.duplicate'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('skill.skillManagement.duplicateFailed')
|
||||
})
|
||||
expect(mocks.push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('exports a published skill from the card action menu', async () => {
|
||||
|
||||
@@ -6,5 +6,5 @@ import { SkillDetailPage } from './detail/page'
|
||||
export default function SkillDetailPageRoute() {
|
||||
const { skillId } = useParams<{ skillId: string }>()
|
||||
|
||||
return <SkillDetailPage skillId={skillId} />
|
||||
return <SkillDetailPage key={skillId} skillId={skillId} />
|
||||
}
|
||||
|
||||
@@ -371,7 +371,18 @@ export function SkillBuilderPanel({
|
||||
}
|
||||
: undefined
|
||||
const [selectedModel, setSelectedModel] = useState<SkillBuilderModel | undefined>()
|
||||
const activeSelectedModel = selectedModel ?? defaultBuilderModel ?? fallbackModel
|
||||
const activeSelectedModel = [selectedModel, defaultBuilderModel, fallbackModel].find(
|
||||
(candidate) =>
|
||||
candidate &&
|
||||
textGenerationModelList.some(
|
||||
(provider) =>
|
||||
provider.provider === candidate.provider &&
|
||||
provider.status === ModelStatusEnum.active &&
|
||||
provider.models.some(
|
||||
(model) => model.model === candidate.model && model.status === ModelStatusEnum.active,
|
||||
),
|
||||
),
|
||||
)
|
||||
const canSendBuilderMessage = !!activeSelectedModel?.provider && !!activeSelectedModel?.model
|
||||
const suggestions = [
|
||||
t(($) => $['skillManagement.detail.builder.exampleIssueTriage']),
|
||||
|
||||
@@ -61,6 +61,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/too
|
||||
import { formatForDisplay, matchesKeyboardEvent, useHotkey } from '@tanstack/react-hotkeys'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs'
|
||||
import { useCallback, useEffect, useEffectEvent, useId, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
|
||||
@@ -189,7 +190,17 @@ export function FileTree({
|
||||
const [draggingPaths, setDraggingPaths] = useState<string[]>([])
|
||||
const [dropTarget, setDropTarget] = useState<SkillDropTarget>()
|
||||
const [collapsedFolderPaths, setCollapsedFolderPaths] = useState<string[]>([])
|
||||
const [skillRenameEditing, setSkillRenameEditing] = useState(false)
|
||||
const [renameRequested, setRenameRequested] = useQueryState(
|
||||
'rename',
|
||||
parseAsBoolean.withDefault(false),
|
||||
)
|
||||
const [skillRenameEditing, setSkillRenameEditing] = useState(
|
||||
() => canEdit && !readonly && renameRequested,
|
||||
)
|
||||
useEffect(() => {
|
||||
// oxlint-disable-next-line eslint-react/set-state-in-effect -- Consume the URL navigation flag after the loaded detail initializes its local editor.
|
||||
if (renameRequested) void setRenameRequested(null)
|
||||
}, [renameRequested, setRenameRequested])
|
||||
const [selectedPaths, setSelectedPaths] = useState<string[]>([])
|
||||
const [selectionAnchorPath, setSelectionAnchorPath] = useState<string>()
|
||||
const [clipboard, setClipboard] = useState<SkillFileClipboard>()
|
||||
|
||||
@@ -212,6 +212,7 @@ export function SkillDetailSidebarActions({
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const queryClient = useQueryClient()
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const router = useRouter()
|
||||
const duplicateMutation = useMutation(
|
||||
consoleQuery.workspaces.current.skills.bySkillId.duplicate.post.mutationOptions(),
|
||||
)
|
||||
@@ -235,9 +236,10 @@ export function SkillDetailSidebarActions({
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onSuccess: (copiedSkill) => {
|
||||
toast.success(t(($) => $['skillManagement.duplicateSuccess']))
|
||||
invalidateSkillListQueries(queryClient)
|
||||
router.push(`/skills/${copiedSkill.id}?rename=true`)
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t(($) => $['skillManagement.duplicateFailed']))
|
||||
|
||||
@@ -32,6 +32,7 @@ import { SkillPublishShortcut } from './publish-bar'
|
||||
import {
|
||||
invalidateSkillDetail,
|
||||
runSkillFileMutation,
|
||||
setSkillDetailCache,
|
||||
SKILL_TAG_CREATE_OPTION_PREFIX,
|
||||
} from './shared'
|
||||
|
||||
@@ -54,9 +55,11 @@ export function SkillTagsEditor({
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [showTagManagement, setShowTagManagement] = useState(false)
|
||||
const [tagSearch, setTagSearch] = useState('')
|
||||
const [draftTags, setDraftTags] = useState<string[]>([])
|
||||
const [draftTags, setDraftTags] = useState<string[] | null>(null)
|
||||
const [isSavingTags, setIsSavingTags] = useState(false)
|
||||
const persistedTags = useMemo(() => detail?.tags ?? [], [detail?.tags])
|
||||
const tags = persistedTags
|
||||
const visibleTags = draftTags ?? tags
|
||||
const metadataMutation = useMutation(
|
||||
consoleQuery.workspaces.current.skills.bySkillId.patch.mutationOptions(),
|
||||
)
|
||||
@@ -81,7 +84,7 @@ export function SkillTagsEditor({
|
||||
|
||||
for (const tag of tags) addOption(tag)
|
||||
for (const tag of tagsQuery.data?.data ?? []) addOption(tag.tag)
|
||||
for (const tag of draftTags) addOption(tag)
|
||||
for (const tag of draftTags ?? []) addOption(tag)
|
||||
const hasExactMatch = options.some(
|
||||
(tag) => tag.name.toLocaleLowerCase() === normalizedTagSearch.toLocaleLowerCase(),
|
||||
)
|
||||
@@ -106,10 +109,11 @@ export function SkillTagsEditor({
|
||||
}),
|
||||
[tagOptions],
|
||||
)
|
||||
const draftTagIds = useMemo(() => draftTags.map(getSkillTagOptionId), [draftTags])
|
||||
const draftTagIds = useMemo(() => visibleTags.map(getSkillTagOptionId), [visibleTags])
|
||||
|
||||
const saveTags = (nextTags: string[]) => {
|
||||
if (!detail || metadataMutation.isPending) return
|
||||
if (!detail || isSavingTags) return
|
||||
setIsSavingTags(true)
|
||||
|
||||
void runSkillFileMutation(fileMutationCoordinator, (expectedUpdatedAt) =>
|
||||
metadataMutation.mutateAsync({
|
||||
@@ -122,7 +126,8 @@ export function SkillTagsEditor({
|
||||
},
|
||||
}),
|
||||
)
|
||||
.then(() => {
|
||||
.then((nextDetail) => {
|
||||
setSkillDetailCache(queryClient, skillId, nextDetail)
|
||||
const addedTag = nextTags.some((tag) => !tags.includes(tag))
|
||||
toast.success(
|
||||
addedTag
|
||||
@@ -141,9 +146,15 @@ export function SkillTagsEditor({
|
||||
invalidateSkillDetail(queryClient, skillId)
|
||||
toast.error(t(($) => $['skillManagement.detail.updateTagsFailed']))
|
||||
})
|
||||
.finally(() => {
|
||||
setDraftTags(null)
|
||||
setIsSavingTags(false)
|
||||
})
|
||||
}
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (isSavingTags) return
|
||||
|
||||
if (open) {
|
||||
setDraftTags(tags)
|
||||
setTagSearch('')
|
||||
@@ -153,10 +164,13 @@ export function SkillTagsEditor({
|
||||
|
||||
setAddOpen(false)
|
||||
setTagSearch('')
|
||||
if (!draftTags) return
|
||||
|
||||
const draftTagSet = new Set(draftTags)
|
||||
const tagsChanged =
|
||||
tags.length !== draftTags.length || tags.some((tag) => !draftTagSet.has(tag))
|
||||
if (tagsChanged) saveTags(draftTags)
|
||||
else setDraftTags(null)
|
||||
}
|
||||
|
||||
const renderTagBadge = (tag: string) => (
|
||||
@@ -168,6 +182,7 @@ export function SkillTagsEditor({
|
||||
{!readonly && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={addOpen || isSavingTags}
|
||||
aria-label={t(($) => $['skillManagement.detail.removeTag'], { tag })}
|
||||
className="flex h-3 max-w-0 shrink-0 cursor-pointer items-center justify-center overflow-hidden text-text-quaternary opacity-0 outline-hidden transition-[max-width,margin,opacity] group-hover/tag:ml-0.5 group-hover/tag:max-w-3 group-hover/tag:opacity-100 hover:text-text-secondary focus-visible:ml-0.5 focus-visible:max-w-3 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
onClick={() => saveTags(tags.filter((currentTag) => currentTag !== tag))}
|
||||
@@ -181,7 +196,7 @@ export function SkillTagsEditor({
|
||||
return (
|
||||
<>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1">
|
||||
{tags.map(renderTagBadge)}
|
||||
{visibleTags.map(renderTagBadge)}
|
||||
{!readonly && (
|
||||
<Combobox<string, true, TagComboboxItem>
|
||||
items={tagItems}
|
||||
@@ -196,7 +211,7 @@ export function SkillTagsEditor({
|
||||
})
|
||||
const createOption = createOptionId ? tagOptionById.get(createOptionId) : undefined
|
||||
if (createOption && isCreateTagOption(createOption)) {
|
||||
setDraftTags((currentTags) => [...currentTags, createOption.name])
|
||||
setDraftTags((currentTags) => [...(currentTags ?? tags), createOption.name])
|
||||
setTagSearch('')
|
||||
return
|
||||
}
|
||||
@@ -216,16 +231,16 @@ export function SkillTagsEditor({
|
||||
>
|
||||
<ComboboxTrigger
|
||||
icon={false}
|
||||
disabled={!detail}
|
||||
disabled={!detail || isSavingTags}
|
||||
aria-label={t(($) => $['skillManagement.detail.addTag'])}
|
||||
className={cn(
|
||||
'h-4.5 w-auto min-w-4.5 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm p-0 text-text-tertiary hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt data-popup-open:bg-state-base-hover',
|
||||
tags.length === 0 && 'border-dashed px-1.25',
|
||||
visibleTags.length === 0 && 'border-dashed px-1.25',
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-0.5 system-2xs-medium-uppercase">
|
||||
<span aria-hidden className="i-ri-add-line size-3 shrink-0" />
|
||||
{tags.length === 0 && t(($) => $['skillManagement.detail.addTag'])}
|
||||
{visibleTags.length === 0 && t(($) => $['skillManagement.detail.addTag'])}
|
||||
</span>
|
||||
</ComboboxTrigger>
|
||||
<ComboboxPortal>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { SkillResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import type { UIEvent } from 'react'
|
||||
import type { DragEvent, UIEvent } from 'react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
@@ -14,6 +14,13 @@ import {
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -48,6 +55,7 @@ import Link from '@/next/link'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { consoleQuery } from '@/service/console'
|
||||
import { downloadBlob } from '@/utils/download'
|
||||
import { formatFileSize } from '@/utils/format'
|
||||
import { fetchSkillArchiveBlob } from './client'
|
||||
import { SkillReferencesList, SkillReferencesListSkeleton } from './detail/skill-metadata'
|
||||
import { getSkillErrorCode, getSkillErrorDetailString, normalizeSkillError } from './error'
|
||||
@@ -413,6 +421,7 @@ function SkillCard({
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const { formatTimeFromNow } = useFormatTimeFromNow()
|
||||
const queryClient = useQueryClient()
|
||||
const router = useRouter()
|
||||
const nameId = useId()
|
||||
const descriptionId = useId()
|
||||
const draftStatusId = useId()
|
||||
@@ -446,9 +455,10 @@ function SkillCard({
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onSuccess: (copiedSkill) => {
|
||||
toast.success(t(($) => $['skillManagement.duplicateSuccess']))
|
||||
invalidateSkillListQueries(queryClient)
|
||||
router.push(`/skills/${copiedSkill.id}?rename=true`)
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t(($) => $['skillManagement.duplicateFailed']))
|
||||
@@ -844,12 +854,175 @@ function SkillGrid({ state }: SkillGridProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function ImportSkillDialogForm({
|
||||
importing,
|
||||
onImport,
|
||||
}: {
|
||||
importing: boolean
|
||||
onImport: (file: File) => void
|
||||
}) {
|
||||
const { t } = useTranslation('skill')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const dragDepthRef = useRef(0)
|
||||
const descriptionId = useId()
|
||||
const errorId = useId()
|
||||
const [file, setFile] = useState<File>()
|
||||
const [invalidFile, setInvalidFile] = useState(false)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
|
||||
const selectFiles = (files: File[]) => {
|
||||
if (importing || files.length === 0) return
|
||||
const [selectedFile] = files
|
||||
if (files.length !== 1 || !selectedFile || !/\.(?:zip|skill)$/iu.test(selectedFile.name)) {
|
||||
setFile(undefined)
|
||||
setInvalidFile(true)
|
||||
return
|
||||
}
|
||||
setFile(selectedFile)
|
||||
setInvalidFile(false)
|
||||
}
|
||||
|
||||
const hasFiles = (event: DragEvent<HTMLDivElement>) =>
|
||||
Array.from(event.dataTransfer.types).includes('Files')
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (file && !importing) onImport(file)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-divider-subtle px-5 py-4">
|
||||
<DialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||
{t(($) => $['skillManagement.importDialog.title'])}
|
||||
</DialogTitle>
|
||||
<DialogClose
|
||||
disabled={importing}
|
||||
render={
|
||||
<IconButton aria-label={tCommon(($) => $['operation.close'])}>
|
||||
<span aria-hidden className="i-ri-close-line size-4" />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t(($) => $['skillManagement.importDialog.title'])}
|
||||
className={cn(
|
||||
'flex min-h-34 flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-components-dropzone-border-alt bg-components-dropzone-bg-alt px-5 py-4',
|
||||
dragging && 'border-components-dropzone-border-accent bg-components-dropzone-bg-accent',
|
||||
)}
|
||||
onDragEnter={(event) => {
|
||||
if (!hasFiles(event)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (importing) return
|
||||
dragDepthRef.current += 1
|
||||
setDragging(true)
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
if (!hasFiles(event)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.dataTransfer.dropEffect = importing ? 'none' : 'copy'
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
if (!hasFiles(event)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
|
||||
if (dragDepthRef.current === 0) setDragging(false)
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
if (!hasFiles(event)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
dragDepthRef.current = 0
|
||||
setDragging(false)
|
||||
selectFiles(Array.from(event.dataTransfer.files))
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".zip,.skill,application/zip"
|
||||
className="hidden"
|
||||
aria-label={t(($) => $['skillManagement.importDialog.browse'])}
|
||||
aria-describedby={invalidFile ? `${descriptionId} ${errorId}` : descriptionId}
|
||||
aria-invalid={invalidFile}
|
||||
disabled={importing}
|
||||
onChange={(event) => {
|
||||
selectFiles(Array.from(event.currentTarget.files ?? []))
|
||||
event.currentTarget.value = ''
|
||||
}}
|
||||
/>
|
||||
<span aria-hidden className="i-ri-file-zip-line size-8 shrink-0 text-text-quaternary" />
|
||||
{file ? (
|
||||
<div className="flex w-full items-center justify-center gap-3">
|
||||
<div className="min-w-0 text-center">
|
||||
<p className="system-sm-medium wrap-anywhere text-text-secondary">{file.name}</p>
|
||||
<p className="system-xs-regular text-text-tertiary">{formatFileSize(file.size)}</p>
|
||||
</div>
|
||||
<IconButton
|
||||
aria-label={tCommon(($) => $['operation.remove'])}
|
||||
disabled={importing}
|
||||
onClick={() => setFile(undefined)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-line size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center system-md-medium text-text-secondary">
|
||||
<Trans
|
||||
ns="skill"
|
||||
i18nKey={($) => $['skillManagement.importDialog.dropzone']}
|
||||
components={{
|
||||
browse: (
|
||||
<button
|
||||
type="button"
|
||||
disabled={importing}
|
||||
aria-describedby={invalidFile ? `${descriptionId} ${errorId}` : descriptionId}
|
||||
className="cursor-pointer rounded-sm text-text-accent focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden disabled:cursor-not-allowed"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
<DialogDescription
|
||||
id={descriptionId}
|
||||
className="text-center system-sm-regular text-text-tertiary"
|
||||
>
|
||||
{t(($) => $['skillManagement.importDialog.description'])}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
{invalidFile && (
|
||||
<p id={errorId} role="alert" className="mt-2 system-xs-regular text-text-destructive">
|
||||
{t(($) => $['skillManagement.importDialog.invalidFile'])}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-divider-subtle px-5 py-4">
|
||||
<DialogClose disabled={importing} render={<Button />}>
|
||||
{tCommon(($) => $['operation.cancel'])}
|
||||
</DialogClose>
|
||||
<Button type="submit" variant="primary" disabled={!file} loading={importing}>
|
||||
{t(($) => $['skillManagement.import'])}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SkillsPage() {
|
||||
const { t } = useTranslation('skill')
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const { canDelete, canEdit } = useSkillPermissions()
|
||||
const importInputRef = useRef<HTMLInputElement>(null)
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
const listViewportRef = useRef<HTMLDivElement>(null)
|
||||
const [showTagManagementModal, setShowTagManagementModal] = useState(false)
|
||||
const [keyword] = useQueryState(skillQueryParamNames.keyword, skillKeywordQueryParser)
|
||||
@@ -910,8 +1083,8 @@ export default function SkillsPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const handleFileChange = (file: File | undefined) => {
|
||||
if (!file || importMutation.isPending || createMutation.isPending) return
|
||||
const handleImport = (file: File) => {
|
||||
if (!canEdit || importMutation.isPending || createMutation.isPending) return
|
||||
|
||||
importMutation.mutate(
|
||||
{
|
||||
@@ -921,6 +1094,7 @@ export default function SkillsPage() {
|
||||
},
|
||||
{
|
||||
onSuccess: (skill) => {
|
||||
setImportOpen(false)
|
||||
toast.success(t(($) => $['skillManagement.importSuccess']))
|
||||
invalidateSkillListQueries(queryClient)
|
||||
router.push(`/skills/${skill.id}`)
|
||||
@@ -946,9 +1120,6 @@ export default function SkillsPage() {
|
||||
}
|
||||
toast.error(t(($) => $['skillManagement.importFailed']))
|
||||
},
|
||||
onSettled: () => {
|
||||
if (importInputRef.current) importInputRef.current.value = ''
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1010,7 +1181,7 @@ export default function SkillsPage() {
|
||||
creating: createMutation.isPending,
|
||||
importing: importMutation.isPending,
|
||||
onCreate: handleCreate,
|
||||
onImport: () => importInputRef.current?.click(),
|
||||
onImport: () => setImportOpen(true),
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
@@ -1051,19 +1222,12 @@ export default function SkillsPage() {
|
||||
</h1>
|
||||
</div>
|
||||
<div className="mt-3.5">
|
||||
<input
|
||||
ref={importInputRef}
|
||||
type="file"
|
||||
accept=".zip,.skill,application/zip"
|
||||
className="hidden"
|
||||
onChange={(event) => handleFileChange(event.currentTarget.files?.[0])}
|
||||
/>
|
||||
<SkillsToolbar
|
||||
canEdit={canEdit}
|
||||
creating={createMutation.isPending}
|
||||
importing={importMutation.isPending}
|
||||
onCreate={handleCreate}
|
||||
onImport={() => importInputRef.current?.click()}
|
||||
onImport={() => setImportOpen(true)}
|
||||
onOpenTagManagement={() => setShowTagManagementModal(true)}
|
||||
/>
|
||||
</div>
|
||||
@@ -1086,6 +1250,16 @@ export default function SkillsPage() {
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<Dialog
|
||||
open={importOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!importMutation.isPending) setImportOpen(open)
|
||||
}}
|
||||
>
|
||||
<DialogContent className="p-0">
|
||||
<ImportSkillDialogForm importing={importMutation.isPending} onImport={handleImport} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<SkillListTagManagementModal
|
||||
show={showTagManagementModal}
|
||||
onClose={() => setShowTagManagementModal(false)}
|
||||
|
||||
@@ -545,6 +545,48 @@
|
||||
"resize.nodeHelp": "استخدم مفاتيح الأسهم لتغيير الحجم. اضغط مطولاً على Shift لخطوات أكبر. يصغّر Home أو Enter الحجم ليتسع للمحتوى.",
|
||||
"resize.size": "العرض {{width}} بكسل، الارتفاع {{height}} بكسل",
|
||||
"resize.width": "العرض {{width}} بكسل",
|
||||
"resourceAccessToken.accessibleResources": "Accessible Resources",
|
||||
"resourceAccessToken.actions": "Actions",
|
||||
"resourceAccessToken.app": "App",
|
||||
"resourceAccessToken.appCount": "{{count}} App",
|
||||
"resourceAccessToken.appsCount": "{{count}} Apps",
|
||||
"resourceAccessToken.appsSection": "Apps",
|
||||
"resourceAccessToken.copied": "Access token copied",
|
||||
"resourceAccessToken.createButton": "Create new token",
|
||||
"resourceAccessToken.createTitle": "Create access token",
|
||||
"resourceAccessToken.created": "Access token created",
|
||||
"resourceAccessToken.createdColumn": "Created",
|
||||
"resourceAccessToken.createdToken": "Save your token",
|
||||
"resourceAccessToken.createdTokenDescription": "Save this access token in a secure place—you won’t be able to view it again. Anyone who has it can use Difyctl to discover and access the resource authorized by this token. If you lose it, create a new token and revoke the old one.",
|
||||
"resourceAccessToken.deleteDescription": "The Access token will be disabled immediately. Calls using this token will be rejected, which may cause the task that is using the access token to interrupt or fail. Once deleted, you will no longer be able to view or modify this Access token.",
|
||||
"resourceAccessToken.deleteTitle": "Delete access token ?",
|
||||
"resourceAccessToken.deleted": "Access token deleted",
|
||||
"resourceAccessToken.done": "Done",
|
||||
"resourceAccessToken.editTitle": "Edit access token",
|
||||
"resourceAccessToken.empty": "No access tokens yet",
|
||||
"resourceAccessToken.emptyDescription": "Create an access token for automated tasks and external systems to discover and access selected resource through Difyctl.",
|
||||
"resourceAccessToken.knowledge": "Knowledge",
|
||||
"resourceAccessToken.knowledgeBasesSection": "Knowledge bases",
|
||||
"resourceAccessToken.knowledgeCount": "{{count}} Knowledge",
|
||||
"resourceAccessToken.knowledgesCount": "{{count}} Knowledges",
|
||||
"resourceAccessToken.name": "Name",
|
||||
"resourceAccessToken.namePlaceholder": "Enter a name",
|
||||
"resourceAccessToken.next": "Next",
|
||||
"resourceAccessToken.noAccessibleResources": "No accessible resources",
|
||||
"resourceAccessToken.noResourceSearchResults": "No matching resources",
|
||||
"resourceAccessToken.noResources": "No API-enabled resources available",
|
||||
"resourceAccessToken.previous": "Previous",
|
||||
"resourceAccessToken.resources": "Resources",
|
||||
"resourceAccessToken.searchPlaceholder": "Search tokens...",
|
||||
"resourceAccessToken.searchResourcesPlaceholder": "Search apps or knowledge bases...",
|
||||
"resourceAccessToken.tabAll": "All",
|
||||
"resourceAccessToken.tabApps": "Apps",
|
||||
"resourceAccessToken.tabKnowledgeBases": "Knowledge bases",
|
||||
"resourceAccessToken.token": "Token",
|
||||
"resourceAccessToken.total": "{{total}} total",
|
||||
"resourceAccessToken.trackingId": "Tracking ID",
|
||||
"settings.accessToken": "Access Tokens",
|
||||
"settings.accessTokenDescription": "Create service API tokens that grant access only to selected apps and knowledge bases.",
|
||||
"settings.agentStrategy": "Agent strategy",
|
||||
"settings.billing": "الفوترة",
|
||||
"settings.customEndpoint": "نقطة نهاية مخصصة",
|
||||
|
||||
@@ -189,6 +189,11 @@
|
||||
"skillManagement.errors.nameConflict": "توجد بالفعل مهارة اسمها \"{{name}}\". أعد تسميته إلى SKILL.md وقم باستيراده مرة أخرى.",
|
||||
"skillManagement.errors.workspaceLimit": "وصلت مساحة العمل الخاصة بك إلى الحد الأقصى وهو 500 مهارة. احذف مهارة لإضافة مهارة جديدة.",
|
||||
"skillManagement.import": "استيراد",
|
||||
"skillManagement.importDialog.browse": "تصفّح",
|
||||
"skillManagement.importDialog.description": "يدعم حزم .zip و.skill التي تحتوي على SKILL.md.",
|
||||
"skillManagement.importDialog.dropzone": "اسحب ملفًا وأفلته، أو <browse>تصفّح الملفات</browse>",
|
||||
"skillManagement.importDialog.invalidFile": "اختر حزمة واحدة بصيغة .zip أو .skill.",
|
||||
"skillManagement.importDialog.title": "استيراد مهارة",
|
||||
"skillManagement.importFailed": "فشل استيراد المهارة.",
|
||||
"skillManagement.importSuccess": "Skill مستورد.",
|
||||
"skillManagement.listLabel": "مهارات مساحة العمل",
|
||||
|
||||
@@ -545,6 +545,48 @@
|
||||
"resize.nodeHelp": "Mit den Pfeiltasten die Größe ändern. Umschalt für größere Schritte halten. Pos1 oder Eingabe verkleinert auf die für den Inhalt benötigte Größe.",
|
||||
"resize.size": "{{width}} Pixel breit, {{height}} Pixel hoch",
|
||||
"resize.width": "Breite: {{width}} Pixel",
|
||||
"resourceAccessToken.accessibleResources": "Accessible Resources",
|
||||
"resourceAccessToken.actions": "Actions",
|
||||
"resourceAccessToken.app": "App",
|
||||
"resourceAccessToken.appCount": "{{count}} App",
|
||||
"resourceAccessToken.appsCount": "{{count}} Apps",
|
||||
"resourceAccessToken.appsSection": "Apps",
|
||||
"resourceAccessToken.copied": "Access token copied",
|
||||
"resourceAccessToken.createButton": "Create new token",
|
||||
"resourceAccessToken.createTitle": "Create access token",
|
||||
"resourceAccessToken.created": "Access token created",
|
||||
"resourceAccessToken.createdColumn": "Created",
|
||||
"resourceAccessToken.createdToken": "Save your token",
|
||||
"resourceAccessToken.createdTokenDescription": "Save this access token in a secure place—you won’t be able to view it again. Anyone who has it can use Difyctl to discover and access the resource authorized by this token. If you lose it, create a new token and revoke the old one.",
|
||||
"resourceAccessToken.deleteDescription": "The Access token will be disabled immediately. Calls using this token will be rejected, which may cause the task that is using the access token to interrupt or fail. Once deleted, you will no longer be able to view or modify this Access token.",
|
||||
"resourceAccessToken.deleteTitle": "Delete access token ?",
|
||||
"resourceAccessToken.deleted": "Access token deleted",
|
||||
"resourceAccessToken.done": "Done",
|
||||
"resourceAccessToken.editTitle": "Edit access token",
|
||||
"resourceAccessToken.empty": "No access tokens yet",
|
||||
"resourceAccessToken.emptyDescription": "Create an access token for automated tasks and external systems to discover and access selected resource through Difyctl.",
|
||||
"resourceAccessToken.knowledge": "Knowledge",
|
||||
"resourceAccessToken.knowledgeBasesSection": "Knowledge bases",
|
||||
"resourceAccessToken.knowledgeCount": "{{count}} Knowledge",
|
||||
"resourceAccessToken.knowledgesCount": "{{count}} Knowledges",
|
||||
"resourceAccessToken.name": "Name",
|
||||
"resourceAccessToken.namePlaceholder": "Enter a name",
|
||||
"resourceAccessToken.next": "Next",
|
||||
"resourceAccessToken.noAccessibleResources": "No accessible resources",
|
||||
"resourceAccessToken.noResourceSearchResults": "No matching resources",
|
||||
"resourceAccessToken.noResources": "No API-enabled resources available",
|
||||
"resourceAccessToken.previous": "Previous",
|
||||
"resourceAccessToken.resources": "Resources",
|
||||
"resourceAccessToken.searchPlaceholder": "Search tokens...",
|
||||
"resourceAccessToken.searchResourcesPlaceholder": "Search apps or knowledge bases...",
|
||||
"resourceAccessToken.tabAll": "All",
|
||||
"resourceAccessToken.tabApps": "Apps",
|
||||
"resourceAccessToken.tabKnowledgeBases": "Knowledge bases",
|
||||
"resourceAccessToken.token": "Token",
|
||||
"resourceAccessToken.total": "{{total}} total",
|
||||
"resourceAccessToken.trackingId": "Tracking ID",
|
||||
"settings.accessToken": "Access Tokens",
|
||||
"settings.accessTokenDescription": "Create service API tokens that grant access only to selected apps and knowledge bases.",
|
||||
"settings.agentStrategy": "Agent strategy",
|
||||
"settings.billing": "Abrechnung",
|
||||
"settings.customEndpoint": "Benutzerdefinierter Endpunkt",
|
||||
|
||||
@@ -189,6 +189,11 @@
|
||||
"skillManagement.errors.nameConflict": "Ein Skill mit dem Namen „{{name}}“ ist bereits vorhanden. Benennen Sie es in SKILL.md um und importieren Sie es erneut.",
|
||||
"skillManagement.errors.workspaceLimit": "Ihr Arbeitsbereich hat die Grenze von 500 Fertigkeiten erreicht. Löschen Sie eine Fertigkeit, um eine neue hinzuzufügen.",
|
||||
"skillManagement.import": "Importieren",
|
||||
"skillManagement.importDialog.browse": "Durchsuchen",
|
||||
"skillManagement.importDialog.description": "Unterstützt .zip- und .skill-Pakete, die SKILL.md enthalten.",
|
||||
"skillManagement.importDialog.dropzone": "Datei hierher ziehen oder <browse>durchsuchen</browse>",
|
||||
"skillManagement.importDialog.invalidFile": "Wählen Sie ein .zip- oder .skill-Paket aus.",
|
||||
"skillManagement.importDialog.title": "Skill importieren",
|
||||
"skillManagement.importFailed": "Der Skill konnte nicht importiert werden.",
|
||||
"skillManagement.importSuccess": "Skill importiert.",
|
||||
"skillManagement.listLabel": "Fähigkeiten am Arbeitsplatz",
|
||||
|
||||
@@ -545,6 +545,48 @@
|
||||
"resize.nodeHelp": "Use arrow keys to resize. Hold Shift for larger steps. Home or Enter shrinks to fit the contents.",
|
||||
"resize.size": "{{width}} pixels wide, {{height}} pixels high",
|
||||
"resize.width": "Width: {{width}} pixels",
|
||||
"resourceAccessToken.accessibleResources": "Accessible Resources",
|
||||
"resourceAccessToken.actions": "Actions",
|
||||
"resourceAccessToken.app": "App",
|
||||
"resourceAccessToken.appCount": "{{count}} App",
|
||||
"resourceAccessToken.appsCount": "{{count}} Apps",
|
||||
"resourceAccessToken.appsSection": "Apps",
|
||||
"resourceAccessToken.copied": "Access token copied",
|
||||
"resourceAccessToken.createButton": "Create new token",
|
||||
"resourceAccessToken.createTitle": "Create access token",
|
||||
"resourceAccessToken.created": "Access token created",
|
||||
"resourceAccessToken.createdColumn": "Created",
|
||||
"resourceAccessToken.createdToken": "Save your token",
|
||||
"resourceAccessToken.createdTokenDescription": "Save this access token in a secure place—you won’t be able to view it again. Anyone who has it can use Difyctl to discover and access the resource authorized by this token. If you lose it, create a new token and revoke the old one.",
|
||||
"resourceAccessToken.deleteDescription": "The Access token will be disabled immediately. Calls using this token will be rejected, which may cause the task that is using the access token to interrupt or fail. Once deleted, you will no longer be able to view or modify this Access token.",
|
||||
"resourceAccessToken.deleteTitle": "Delete access token ?",
|
||||
"resourceAccessToken.deleted": "Access token deleted",
|
||||
"resourceAccessToken.done": "Done",
|
||||
"resourceAccessToken.editTitle": "Edit access token",
|
||||
"resourceAccessToken.empty": "No access tokens yet",
|
||||
"resourceAccessToken.emptyDescription": "Create an access token for automated tasks and external systems to discover and access selected resource through Difyctl.",
|
||||
"resourceAccessToken.knowledge": "Knowledge",
|
||||
"resourceAccessToken.knowledgeBasesSection": "Knowledge bases",
|
||||
"resourceAccessToken.knowledgeCount": "{{count}} Knowledge",
|
||||
"resourceAccessToken.knowledgesCount": "{{count}} Knowledges",
|
||||
"resourceAccessToken.name": "Name",
|
||||
"resourceAccessToken.namePlaceholder": "Enter a name",
|
||||
"resourceAccessToken.next": "Next",
|
||||
"resourceAccessToken.noAccessibleResources": "No accessible resources",
|
||||
"resourceAccessToken.noResourceSearchResults": "No matching resources",
|
||||
"resourceAccessToken.noResources": "No API-enabled resources available",
|
||||
"resourceAccessToken.previous": "Previous",
|
||||
"resourceAccessToken.resources": "Resources",
|
||||
"resourceAccessToken.searchPlaceholder": "Search tokens...",
|
||||
"resourceAccessToken.searchResourcesPlaceholder": "Search apps or knowledge bases...",
|
||||
"resourceAccessToken.tabAll": "All",
|
||||
"resourceAccessToken.tabApps": "Apps",
|
||||
"resourceAccessToken.tabKnowledgeBases": "Knowledge bases",
|
||||
"resourceAccessToken.token": "Token",
|
||||
"resourceAccessToken.total": "{{total}} total",
|
||||
"resourceAccessToken.trackingId": "Tracking ID",
|
||||
"settings.accessToken": "Access Tokens",
|
||||
"settings.accessTokenDescription": "Creates workspace machine credentials for automation tasks and external systems, used only to discover and run specified applications via Difyctl.",
|
||||
"settings.agentStrategy": "Agent Strategy",
|
||||
"settings.billing": "Billing",
|
||||
"settings.customEndpoint": "Custom Endpoint",
|
||||
|
||||
@@ -189,6 +189,11 @@
|
||||
"skillManagement.errors.nameConflict": "A skill named \"{{name}}\" already exists. Rename it in SKILL.md and import again.",
|
||||
"skillManagement.errors.workspaceLimit": "Your workspace has reached the limit of 500 skills. Delete a skill to add a new one.",
|
||||
"skillManagement.import": "Import",
|
||||
"skillManagement.importDialog.browse": "Browse",
|
||||
"skillManagement.importDialog.description": "Supports .zip and .skill packages that contain SKILL.md.",
|
||||
"skillManagement.importDialog.dropzone": "Drag and drop file, or <browse>Browse</browse>",
|
||||
"skillManagement.importDialog.invalidFile": "Choose one .zip or .skill package.",
|
||||
"skillManagement.importDialog.title": "Import skill",
|
||||
"skillManagement.importFailed": "Failed to import skill.",
|
||||
"skillManagement.importSuccess": "Skill imported.",
|
||||
"skillManagement.listLabel": "Workspace skills",
|
||||
|
||||
@@ -545,6 +545,48 @@
|
||||
"resize.nodeHelp": "Usa las flechas para cambiar el tamaño. Mantén Mayús para pasos mayores. Inicio o Intro reduce el tamaño para ajustarlo al contenido.",
|
||||
"resize.size": "{{width}} píxeles de ancho, {{height}} píxeles de alto",
|
||||
"resize.width": "Ancho: {{width}} píxeles",
|
||||
"resourceAccessToken.accessibleResources": "Accessible Resources",
|
||||
"resourceAccessToken.actions": "Actions",
|
||||
"resourceAccessToken.app": "App",
|
||||
"resourceAccessToken.appCount": "{{count}} App",
|
||||
"resourceAccessToken.appsCount": "{{count}} Apps",
|
||||
"resourceAccessToken.appsSection": "Apps",
|
||||
"resourceAccessToken.copied": "Access token copied",
|
||||
"resourceAccessToken.createButton": "Create new token",
|
||||
"resourceAccessToken.createTitle": "Create access token",
|
||||
"resourceAccessToken.created": "Access token created",
|
||||
"resourceAccessToken.createdColumn": "Created",
|
||||
"resourceAccessToken.createdToken": "Save your token",
|
||||
"resourceAccessToken.createdTokenDescription": "Save this access token in a secure place—you won’t be able to view it again. Anyone who has it can use Difyctl to discover and access the resource authorized by this token. If you lose it, create a new token and revoke the old one.",
|
||||
"resourceAccessToken.deleteDescription": "The Access token will be disabled immediately. Calls using this token will be rejected, which may cause the task that is using the access token to interrupt or fail. Once deleted, you will no longer be able to view or modify this Access token.",
|
||||
"resourceAccessToken.deleteTitle": "Delete access token ?",
|
||||
"resourceAccessToken.deleted": "Access token deleted",
|
||||
"resourceAccessToken.done": "Done",
|
||||
"resourceAccessToken.editTitle": "Edit access token",
|
||||
"resourceAccessToken.empty": "No access tokens yet",
|
||||
"resourceAccessToken.emptyDescription": "Create an access token for automated tasks and external systems to discover and access selected resource through Difyctl.",
|
||||
"resourceAccessToken.knowledge": "Knowledge",
|
||||
"resourceAccessToken.knowledgeBasesSection": "Knowledge bases",
|
||||
"resourceAccessToken.knowledgeCount": "{{count}} Knowledge",
|
||||
"resourceAccessToken.knowledgesCount": "{{count}} Knowledges",
|
||||
"resourceAccessToken.name": "Name",
|
||||
"resourceAccessToken.namePlaceholder": "Enter a name",
|
||||
"resourceAccessToken.next": "Next",
|
||||
"resourceAccessToken.noAccessibleResources": "No accessible resources",
|
||||
"resourceAccessToken.noResourceSearchResults": "No matching resources",
|
||||
"resourceAccessToken.noResources": "No API-enabled resources available",
|
||||
"resourceAccessToken.previous": "Previous",
|
||||
"resourceAccessToken.resources": "Resources",
|
||||
"resourceAccessToken.searchPlaceholder": "Search tokens...",
|
||||
"resourceAccessToken.searchResourcesPlaceholder": "Search apps or knowledge bases...",
|
||||
"resourceAccessToken.tabAll": "All",
|
||||
"resourceAccessToken.tabApps": "Apps",
|
||||
"resourceAccessToken.tabKnowledgeBases": "Knowledge bases",
|
||||
"resourceAccessToken.token": "Token",
|
||||
"resourceAccessToken.total": "{{total}} total",
|
||||
"resourceAccessToken.trackingId": "Tracking ID",
|
||||
"settings.accessToken": "Access Tokens",
|
||||
"settings.accessTokenDescription": "Create service API tokens that grant access only to selected apps and knowledge bases.",
|
||||
"settings.agentStrategy": "Agent strategy",
|
||||
"settings.billing": "Facturación",
|
||||
"settings.customEndpoint": "Endpoint personalizado",
|
||||
|
||||
@@ -189,6 +189,11 @@
|
||||
"skillManagement.errors.nameConflict": "Ya existe una habilidad denominada \"{{name}}\". Cambie el nombre a SKILL.md e impórtelo nuevamente.",
|
||||
"skillManagement.errors.workspaceLimit": "Tu espacio de trabajo ha alcanzado el límite de 500 habilidades. Elimina una habilidad para agregar una nueva.",
|
||||
"skillManagement.import": "Importar",
|
||||
"skillManagement.importDialog.browse": "Examinar",
|
||||
"skillManagement.importDialog.description": "Se admiten paquetes .zip y .skill que contengan SKILL.md.",
|
||||
"skillManagement.importDialog.dropzone": "Arrastra y suelta un archivo o <browse>examina los archivos</browse>",
|
||||
"skillManagement.importDialog.invalidFile": "Selecciona un paquete .zip o .skill.",
|
||||
"skillManagement.importDialog.title": "Importar habilidad",
|
||||
"skillManagement.importFailed": "No se pudo importar la habilidad.",
|
||||
"skillManagement.importSuccess": "Skill importado.",
|
||||
"skillManagement.listLabel": "Habilidades en el espacio de trabajo",
|
||||
|
||||
@@ -545,6 +545,48 @@
|
||||
"resize.nodeHelp": "با کلیدهای جهت اندازه را تغییر دهید. برای گامهای بزرگتر Shift را نگه دارید. Home یا Enter اندازه را تا حد لازم برای نمایش محتوا کوچک میکند.",
|
||||
"resize.size": "عرض {{width}} پیکسل، ارتفاع {{height}} پیکسل",
|
||||
"resize.width": "عرض: {{width}} پیکسل",
|
||||
"resourceAccessToken.accessibleResources": "Accessible Resources",
|
||||
"resourceAccessToken.actions": "Actions",
|
||||
"resourceAccessToken.app": "App",
|
||||
"resourceAccessToken.appCount": "{{count}} App",
|
||||
"resourceAccessToken.appsCount": "{{count}} Apps",
|
||||
"resourceAccessToken.appsSection": "Apps",
|
||||
"resourceAccessToken.copied": "Access token copied",
|
||||
"resourceAccessToken.createButton": "Create new token",
|
||||
"resourceAccessToken.createTitle": "Create access token",
|
||||
"resourceAccessToken.created": "Access token created",
|
||||
"resourceAccessToken.createdColumn": "Created",
|
||||
"resourceAccessToken.createdToken": "Save your token",
|
||||
"resourceAccessToken.createdTokenDescription": "Save this access token in a secure place—you won’t be able to view it again. Anyone who has it can use Difyctl to discover and access the resource authorized by this token. If you lose it, create a new token and revoke the old one.",
|
||||
"resourceAccessToken.deleteDescription": "The Access token will be disabled immediately. Calls using this token will be rejected, which may cause the task that is using the access token to interrupt or fail. Once deleted, you will no longer be able to view or modify this Access token.",
|
||||
"resourceAccessToken.deleteTitle": "Delete access token ?",
|
||||
"resourceAccessToken.deleted": "Access token deleted",
|
||||
"resourceAccessToken.done": "Done",
|
||||
"resourceAccessToken.editTitle": "Edit access token",
|
||||
"resourceAccessToken.empty": "No access tokens yet",
|
||||
"resourceAccessToken.emptyDescription": "Create an access token for automated tasks and external systems to discover and access selected resource through Difyctl.",
|
||||
"resourceAccessToken.knowledge": "Knowledge",
|
||||
"resourceAccessToken.knowledgeBasesSection": "Knowledge bases",
|
||||
"resourceAccessToken.knowledgeCount": "{{count}} Knowledge",
|
||||
"resourceAccessToken.knowledgesCount": "{{count}} Knowledges",
|
||||
"resourceAccessToken.name": "Name",
|
||||
"resourceAccessToken.namePlaceholder": "Enter a name",
|
||||
"resourceAccessToken.next": "Next",
|
||||
"resourceAccessToken.noAccessibleResources": "No accessible resources",
|
||||
"resourceAccessToken.noResourceSearchResults": "No matching resources",
|
||||
"resourceAccessToken.noResources": "No API-enabled resources available",
|
||||
"resourceAccessToken.previous": "Previous",
|
||||
"resourceAccessToken.resources": "Resources",
|
||||
"resourceAccessToken.searchPlaceholder": "Search tokens...",
|
||||
"resourceAccessToken.searchResourcesPlaceholder": "Search apps or knowledge bases...",
|
||||
"resourceAccessToken.tabAll": "All",
|
||||
"resourceAccessToken.tabApps": "Apps",
|
||||
"resourceAccessToken.tabKnowledgeBases": "Knowledge bases",
|
||||
"resourceAccessToken.token": "Token",
|
||||
"resourceAccessToken.total": "{{total}} total",
|
||||
"resourceAccessToken.trackingId": "Tracking ID",
|
||||
"settings.accessToken": "Access Tokens",
|
||||
"settings.accessTokenDescription": "Create service API tokens that grant access only to selected apps and knowledge bases.",
|
||||
"settings.agentStrategy": "Agent strategy",
|
||||
"settings.billing": "صورتحساب",
|
||||
"settings.customEndpoint": "نقطه پایانی سفارشی",
|
||||
|
||||
@@ -189,6 +189,11 @@
|
||||
"skillManagement.errors.nameConflict": "مهارتی به نام \"{{name}}\" از قبل وجود دارد. نام آن را در SKILL.md تغییر دهید و دوباره وارد کنید.",
|
||||
"skillManagement.errors.workspaceLimit": "فضای کاری شما به حداکثر 500 مهارت رسیده است. برای افزودن مهارت جدید، مهارت را حذف کنید.",
|
||||
"skillManagement.import": "وارد کردن",
|
||||
"skillManagement.importDialog.browse": "مرور",
|
||||
"skillManagement.importDialog.description": "بستههای .zip و .skill حاوی SKILL.md پشتیبانی میشوند.",
|
||||
"skillManagement.importDialog.dropzone": "فایل را بکشید و رها کنید یا <browse>مرور کنید</browse>",
|
||||
"skillManagement.importDialog.invalidFile": "یک بستهٔ .zip یا .skill انتخاب کنید.",
|
||||
"skillManagement.importDialog.title": "وارد کردن مهارت",
|
||||
"skillManagement.importFailed": "وارد کردن مهارت انجام نشد.",
|
||||
"skillManagement.importSuccess": "Skill وارد شده است.",
|
||||
"skillManagement.listLabel": "مهارت های فضای کاری",
|
||||
|
||||
@@ -545,6 +545,48 @@
|
||||
"resize.nodeHelp": "Utilisez les flèches pour redimensionner. Maintenez Maj pour de plus grands pas. Début ou Entrée réduit la taille au minimum nécessaire au contenu.",
|
||||
"resize.size": "{{width}} pixels de large, {{height}} pixels de haut",
|
||||
"resize.width": "Largeur : {{width}} pixels",
|
||||
"resourceAccessToken.accessibleResources": "Accessible Resources",
|
||||
"resourceAccessToken.actions": "Actions",
|
||||
"resourceAccessToken.app": "App",
|
||||
"resourceAccessToken.appCount": "{{count}} App",
|
||||
"resourceAccessToken.appsCount": "{{count}} Apps",
|
||||
"resourceAccessToken.appsSection": "Apps",
|
||||
"resourceAccessToken.copied": "Access token copied",
|
||||
"resourceAccessToken.createButton": "Create new token",
|
||||
"resourceAccessToken.createTitle": "Create access token",
|
||||
"resourceAccessToken.created": "Access token created",
|
||||
"resourceAccessToken.createdColumn": "Created",
|
||||
"resourceAccessToken.createdToken": "Save your token",
|
||||
"resourceAccessToken.createdTokenDescription": "Save this access token in a secure place—you won’t be able to view it again. Anyone who has it can use Difyctl to discover and access the resource authorized by this token. If you lose it, create a new token and revoke the old one.",
|
||||
"resourceAccessToken.deleteDescription": "The Access token will be disabled immediately. Calls using this token will be rejected, which may cause the task that is using the access token to interrupt or fail. Once deleted, you will no longer be able to view or modify this Access token.",
|
||||
"resourceAccessToken.deleteTitle": "Delete access token ?",
|
||||
"resourceAccessToken.deleted": "Access token deleted",
|
||||
"resourceAccessToken.done": "Done",
|
||||
"resourceAccessToken.editTitle": "Edit access token",
|
||||
"resourceAccessToken.empty": "No access tokens yet",
|
||||
"resourceAccessToken.emptyDescription": "Create an access token for automated tasks and external systems to discover and access selected resource through Difyctl.",
|
||||
"resourceAccessToken.knowledge": "Knowledge",
|
||||
"resourceAccessToken.knowledgeBasesSection": "Knowledge bases",
|
||||
"resourceAccessToken.knowledgeCount": "{{count}} Knowledge",
|
||||
"resourceAccessToken.knowledgesCount": "{{count}} Knowledges",
|
||||
"resourceAccessToken.name": "Name",
|
||||
"resourceAccessToken.namePlaceholder": "Enter a name",
|
||||
"resourceAccessToken.next": "Next",
|
||||
"resourceAccessToken.noAccessibleResources": "No accessible resources",
|
||||
"resourceAccessToken.noResourceSearchResults": "No matching resources",
|
||||
"resourceAccessToken.noResources": "No API-enabled resources available",
|
||||
"resourceAccessToken.previous": "Previous",
|
||||
"resourceAccessToken.resources": "Resources",
|
||||
"resourceAccessToken.searchPlaceholder": "Search tokens...",
|
||||
"resourceAccessToken.searchResourcesPlaceholder": "Search apps or knowledge bases...",
|
||||
"resourceAccessToken.tabAll": "All",
|
||||
"resourceAccessToken.tabApps": "Apps",
|
||||
"resourceAccessToken.tabKnowledgeBases": "Knowledge bases",
|
||||
"resourceAccessToken.token": "Token",
|
||||
"resourceAccessToken.total": "{{total}} total",
|
||||
"resourceAccessToken.trackingId": "Tracking ID",
|
||||
"settings.accessToken": "Access Tokens",
|
||||
"settings.accessTokenDescription": "Create service API tokens that grant access only to selected apps and knowledge bases.",
|
||||
"settings.agentStrategy": "Agent strategy",
|
||||
"settings.billing": "Facturation",
|
||||
"settings.customEndpoint": "Point de terminaison personnalisé",
|
||||
|
||||
@@ -189,6 +189,11 @@
|
||||
"skillManagement.errors.nameConflict": "Une compétence nommée \"{{name}}\" existe déjà. Renommez-le en SKILL.md et importez-le à nouveau.",
|
||||
"skillManagement.errors.workspaceLimit": "Votre espace de travail a atteint la limite de 500 compétences. Supprimez une compétence pour en ajouter une nouvelle.",
|
||||
"skillManagement.import": "Importer",
|
||||
"skillManagement.importDialog.browse": "Parcourir",
|
||||
"skillManagement.importDialog.description": "Les paquets .zip et .skill contenant SKILL.md sont pris en charge.",
|
||||
"skillManagement.importDialog.dropzone": "Glissez-déposez un fichier ou <browse>parcourez les fichiers</browse>",
|
||||
"skillManagement.importDialog.invalidFile": "Choisissez un paquet .zip ou .skill.",
|
||||
"skillManagement.importDialog.title": "Importer une compétence",
|
||||
"skillManagement.importFailed": "Échec de l'importation de la compétence.",
|
||||
"skillManagement.importSuccess": "Skill importé.",
|
||||
"skillManagement.listLabel": "Compétences en espace de travail",
|
||||
|
||||
@@ -545,6 +545,48 @@
|
||||
"resize.nodeHelp": "आकार बदलने के लिए तीर कुंजियाँ दबाएँ। बड़े बदलाव के लिए Shift दबाए रखें। Home या Enter आकार को सामग्री समाने लायक न्यूनतम आकार तक घटाता है।",
|
||||
"resize.size": "चौड़ाई {{width}} पिक्सेल, ऊँचाई {{height}} पिक्सेल",
|
||||
"resize.width": "चौड़ाई: {{width}} पिक्सेल",
|
||||
"resourceAccessToken.accessibleResources": "Accessible Resources",
|
||||
"resourceAccessToken.actions": "Actions",
|
||||
"resourceAccessToken.app": "App",
|
||||
"resourceAccessToken.appCount": "{{count}} App",
|
||||
"resourceAccessToken.appsCount": "{{count}} Apps",
|
||||
"resourceAccessToken.appsSection": "Apps",
|
||||
"resourceAccessToken.copied": "Access token copied",
|
||||
"resourceAccessToken.createButton": "Create new token",
|
||||
"resourceAccessToken.createTitle": "Create access token",
|
||||
"resourceAccessToken.created": "Access token created",
|
||||
"resourceAccessToken.createdColumn": "Created",
|
||||
"resourceAccessToken.createdToken": "Save your token",
|
||||
"resourceAccessToken.createdTokenDescription": "Save this access token in a secure place—you won’t be able to view it again. Anyone who has it can use Difyctl to discover and access the resource authorized by this token. If you lose it, create a new token and revoke the old one.",
|
||||
"resourceAccessToken.deleteDescription": "The Access token will be disabled immediately. Calls using this token will be rejected, which may cause the task that is using the access token to interrupt or fail. Once deleted, you will no longer be able to view or modify this Access token.",
|
||||
"resourceAccessToken.deleteTitle": "Delete access token ?",
|
||||
"resourceAccessToken.deleted": "Access token deleted",
|
||||
"resourceAccessToken.done": "Done",
|
||||
"resourceAccessToken.editTitle": "Edit access token",
|
||||
"resourceAccessToken.empty": "No access tokens yet",
|
||||
"resourceAccessToken.emptyDescription": "Create an access token for automated tasks and external systems to discover and access selected resource through Difyctl.",
|
||||
"resourceAccessToken.knowledge": "Knowledge",
|
||||
"resourceAccessToken.knowledgeBasesSection": "Knowledge bases",
|
||||
"resourceAccessToken.knowledgeCount": "{{count}} Knowledge",
|
||||
"resourceAccessToken.knowledgesCount": "{{count}} Knowledges",
|
||||
"resourceAccessToken.name": "Name",
|
||||
"resourceAccessToken.namePlaceholder": "Enter a name",
|
||||
"resourceAccessToken.next": "Next",
|
||||
"resourceAccessToken.noAccessibleResources": "No accessible resources",
|
||||
"resourceAccessToken.noResourceSearchResults": "No matching resources",
|
||||
"resourceAccessToken.noResources": "No API-enabled resources available",
|
||||
"resourceAccessToken.previous": "Previous",
|
||||
"resourceAccessToken.resources": "Resources",
|
||||
"resourceAccessToken.searchPlaceholder": "Search tokens...",
|
||||
"resourceAccessToken.searchResourcesPlaceholder": "Search apps or knowledge bases...",
|
||||
"resourceAccessToken.tabAll": "All",
|
||||
"resourceAccessToken.tabApps": "Apps",
|
||||
"resourceAccessToken.tabKnowledgeBases": "Knowledge bases",
|
||||
"resourceAccessToken.token": "Token",
|
||||
"resourceAccessToken.total": "{{total}} total",
|
||||
"resourceAccessToken.trackingId": "Tracking ID",
|
||||
"settings.accessToken": "Access Tokens",
|
||||
"settings.accessTokenDescription": "Create service API tokens that grant access only to selected apps and knowledge bases.",
|
||||
"settings.agentStrategy": "Agent strategy",
|
||||
"settings.billing": "बिलिंग",
|
||||
"settings.customEndpoint": "कस्टम एंडपॉइंट",
|
||||
|
||||
@@ -189,6 +189,11 @@
|
||||
"skillManagement.errors.nameConflict": "\"{{name}}\" नामक कौशल पहले से मौजूद है। इसे SKILL.md में नाम बदलें और फिर से आयात करें।",
|
||||
"skillManagement.errors.workspaceLimit": "आपका कार्यक्षेत्र 500 कौशल की सीमा तक पहुंच गया है। नया जोड़ने के लिए एक कौशल हटाएँ।",
|
||||
"skillManagement.import": "आयात करें",
|
||||
"skillManagement.importDialog.browse": "ब्राउज़ करें",
|
||||
"skillManagement.importDialog.description": "SKILL.md वाली .zip और .skill पैकेज फ़ाइलें समर्थित हैं।",
|
||||
"skillManagement.importDialog.dropzone": "फ़ाइल खींचकर छोड़ें, या <browse>ब्राउज़ करें</browse>",
|
||||
"skillManagement.importDialog.invalidFile": "एक .zip या .skill पैकेज चुनें।",
|
||||
"skillManagement.importDialog.title": "स्किल इम्पोर्ट करें",
|
||||
"skillManagement.importFailed": "कौशल आयात करने में विफल.",
|
||||
"skillManagement.importSuccess": "Skill आयातित.",
|
||||
"skillManagement.listLabel": "कार्यस्थल कौशल",
|
||||
|
||||
@@ -545,6 +545,48 @@
|
||||
"resize.nodeHelp": "Gunakan tombol panah untuk mengubah ukuran. Tahan Shift untuk langkah lebih besar. Home atau Enter memperkecil ukuran agar pas dengan konten.",
|
||||
"resize.size": "Lebar {{width}} piksel, tinggi {{height}} piksel",
|
||||
"resize.width": "Lebar: {{width}} piksel",
|
||||
"resourceAccessToken.accessibleResources": "Accessible Resources",
|
||||
"resourceAccessToken.actions": "Actions",
|
||||
"resourceAccessToken.app": "App",
|
||||
"resourceAccessToken.appCount": "{{count}} App",
|
||||
"resourceAccessToken.appsCount": "{{count}} Apps",
|
||||
"resourceAccessToken.appsSection": "Apps",
|
||||
"resourceAccessToken.copied": "Access token copied",
|
||||
"resourceAccessToken.createButton": "Create new token",
|
||||
"resourceAccessToken.createTitle": "Create access token",
|
||||
"resourceAccessToken.created": "Access token created",
|
||||
"resourceAccessToken.createdColumn": "Created",
|
||||
"resourceAccessToken.createdToken": "Save your token",
|
||||
"resourceAccessToken.createdTokenDescription": "Save this access token in a secure place—you won’t be able to view it again. Anyone who has it can use Difyctl to discover and access the resource authorized by this token. If you lose it, create a new token and revoke the old one.",
|
||||
"resourceAccessToken.deleteDescription": "The Access token will be disabled immediately. Calls using this token will be rejected, which may cause the task that is using the access token to interrupt or fail. Once deleted, you will no longer be able to view or modify this Access token.",
|
||||
"resourceAccessToken.deleteTitle": "Delete access token ?",
|
||||
"resourceAccessToken.deleted": "Access token deleted",
|
||||
"resourceAccessToken.done": "Done",
|
||||
"resourceAccessToken.editTitle": "Edit access token",
|
||||
"resourceAccessToken.empty": "No access tokens yet",
|
||||
"resourceAccessToken.emptyDescription": "Create an access token for automated tasks and external systems to discover and access selected resource through Difyctl.",
|
||||
"resourceAccessToken.knowledge": "Knowledge",
|
||||
"resourceAccessToken.knowledgeBasesSection": "Knowledge bases",
|
||||
"resourceAccessToken.knowledgeCount": "{{count}} Knowledge",
|
||||
"resourceAccessToken.knowledgesCount": "{{count}} Knowledges",
|
||||
"resourceAccessToken.name": "Name",
|
||||
"resourceAccessToken.namePlaceholder": "Enter a name",
|
||||
"resourceAccessToken.next": "Next",
|
||||
"resourceAccessToken.noAccessibleResources": "No accessible resources",
|
||||
"resourceAccessToken.noResourceSearchResults": "No matching resources",
|
||||
"resourceAccessToken.noResources": "No API-enabled resources available",
|
||||
"resourceAccessToken.previous": "Previous",
|
||||
"resourceAccessToken.resources": "Resources",
|
||||
"resourceAccessToken.searchPlaceholder": "Search tokens...",
|
||||
"resourceAccessToken.searchResourcesPlaceholder": "Search apps or knowledge bases...",
|
||||
"resourceAccessToken.tabAll": "All",
|
||||
"resourceAccessToken.tabApps": "Apps",
|
||||
"resourceAccessToken.tabKnowledgeBases": "Knowledge bases",
|
||||
"resourceAccessToken.token": "Token",
|
||||
"resourceAccessToken.total": "{{total}} total",
|
||||
"resourceAccessToken.trackingId": "Tracking ID",
|
||||
"settings.accessToken": "Access Tokens",
|
||||
"settings.accessTokenDescription": "Create service API tokens that grant access only to selected apps and knowledge bases.",
|
||||
"settings.agentStrategy": "Agent strategy",
|
||||
"settings.billing": "Penagihan",
|
||||
"settings.customEndpoint": "Endpoint Kustom",
|
||||
|
||||
@@ -189,6 +189,11 @@
|
||||
"skillManagement.errors.nameConflict": "Skill bernama \"{{name}}\" sudah ada. Ubah namanya menjadi SKILL.md dan impor lagi.",
|
||||
"skillManagement.errors.workspaceLimit": "Ruang kerja Anda telah mencapai batas 500 keterampilan. Hapus keterampilan untuk menambahkan keterampilan baru.",
|
||||
"skillManagement.import": "Impor",
|
||||
"skillManagement.importDialog.browse": "Telusuri",
|
||||
"skillManagement.importDialog.description": "Mendukung paket .zip dan .skill yang berisi SKILL.md.",
|
||||
"skillManagement.importDialog.dropzone": "Seret dan lepas file, atau <browse>telusuri file</browse>",
|
||||
"skillManagement.importDialog.invalidFile": "Pilih satu paket .zip atau .skill.",
|
||||
"skillManagement.importDialog.title": "Impor keterampilan",
|
||||
"skillManagement.importFailed": "Gagal mengimpor keterampilan.",
|
||||
"skillManagement.importSuccess": "Skill diimpor.",
|
||||
"skillManagement.listLabel": "Keterampilan ruang kerja",
|
||||
|
||||
@@ -545,6 +545,48 @@
|
||||
"resize.nodeHelp": "Usa le frecce per ridimensionare. Tieni premuto Maiusc per incrementi maggiori. Home o Invio riduce le dimensioni adattandole al contenuto.",
|
||||
"resize.size": "{{width}} pixel di larghezza, {{height}} pixel di altezza",
|
||||
"resize.width": "Larghezza: {{width}} pixel",
|
||||
"resourceAccessToken.accessibleResources": "Accessible Resources",
|
||||
"resourceAccessToken.actions": "Actions",
|
||||
"resourceAccessToken.app": "App",
|
||||
"resourceAccessToken.appCount": "{{count}} App",
|
||||
"resourceAccessToken.appsCount": "{{count}} Apps",
|
||||
"resourceAccessToken.appsSection": "Apps",
|
||||
"resourceAccessToken.copied": "Access token copied",
|
||||
"resourceAccessToken.createButton": "Create new token",
|
||||
"resourceAccessToken.createTitle": "Create access token",
|
||||
"resourceAccessToken.created": "Access token created",
|
||||
"resourceAccessToken.createdColumn": "Created",
|
||||
"resourceAccessToken.createdToken": "Save your token",
|
||||
"resourceAccessToken.createdTokenDescription": "Save this access token in a secure place—you won’t be able to view it again. Anyone who has it can use Difyctl to discover and access the resource authorized by this token. If you lose it, create a new token and revoke the old one.",
|
||||
"resourceAccessToken.deleteDescription": "The Access token will be disabled immediately. Calls using this token will be rejected, which may cause the task that is using the access token to interrupt or fail. Once deleted, you will no longer be able to view or modify this Access token.",
|
||||
"resourceAccessToken.deleteTitle": "Delete access token ?",
|
||||
"resourceAccessToken.deleted": "Access token deleted",
|
||||
"resourceAccessToken.done": "Done",
|
||||
"resourceAccessToken.editTitle": "Edit access token",
|
||||
"resourceAccessToken.empty": "No access tokens yet",
|
||||
"resourceAccessToken.emptyDescription": "Create an access token for automated tasks and external systems to discover and access selected resource through Difyctl.",
|
||||
"resourceAccessToken.knowledge": "Knowledge",
|
||||
"resourceAccessToken.knowledgeBasesSection": "Knowledge bases",
|
||||
"resourceAccessToken.knowledgeCount": "{{count}} Knowledge",
|
||||
"resourceAccessToken.knowledgesCount": "{{count}} Knowledges",
|
||||
"resourceAccessToken.name": "Name",
|
||||
"resourceAccessToken.namePlaceholder": "Enter a name",
|
||||
"resourceAccessToken.next": "Next",
|
||||
"resourceAccessToken.noAccessibleResources": "No accessible resources",
|
||||
"resourceAccessToken.noResourceSearchResults": "No matching resources",
|
||||
"resourceAccessToken.noResources": "No API-enabled resources available",
|
||||
"resourceAccessToken.previous": "Previous",
|
||||
"resourceAccessToken.resources": "Resources",
|
||||
"resourceAccessToken.searchPlaceholder": "Search tokens...",
|
||||
"resourceAccessToken.searchResourcesPlaceholder": "Search apps or knowledge bases...",
|
||||
"resourceAccessToken.tabAll": "All",
|
||||
"resourceAccessToken.tabApps": "Apps",
|
||||
"resourceAccessToken.tabKnowledgeBases": "Knowledge bases",
|
||||
"resourceAccessToken.token": "Token",
|
||||
"resourceAccessToken.total": "{{total}} total",
|
||||
"resourceAccessToken.trackingId": "Tracking ID",
|
||||
"settings.accessToken": "Access Tokens",
|
||||
"settings.accessTokenDescription": "Create service API tokens that grant access only to selected apps and knowledge bases.",
|
||||
"settings.agentStrategy": "Agent strategy",
|
||||
"settings.billing": "Fatturazione",
|
||||
"settings.customEndpoint": "Endpoint personalizzato",
|
||||
|
||||
@@ -189,6 +189,11 @@
|
||||
"skillManagement.errors.nameConflict": "Esiste già una competenza denominata \"{{name}}\". Rinominalo in SKILL.md e importalo di nuovo.",
|
||||
"skillManagement.errors.workspaceLimit": "La tua area di lavoro ha raggiunto il limite di 500 competenze. Elimina una competenza per aggiungerne una nuova.",
|
||||
"skillManagement.import": "Importa",
|
||||
"skillManagement.importDialog.browse": "Sfoglia",
|
||||
"skillManagement.importDialog.description": "Sono supportati i pacchetti .zip e .skill che contengono SKILL.md.",
|
||||
"skillManagement.importDialog.dropzone": "Trascina e rilascia un file oppure <browse>sfoglia</browse>",
|
||||
"skillManagement.importDialog.invalidFile": "Scegli un pacchetto .zip o .skill.",
|
||||
"skillManagement.importDialog.title": "Importa competenza",
|
||||
"skillManagement.importFailed": "Impossibile importare la competenza.",
|
||||
"skillManagement.importSuccess": "Skill importato.",
|
||||
"skillManagement.listLabel": "Abilità nello spazio di lavoro",
|
||||
|
||||
@@ -545,6 +545,48 @@
|
||||
"resize.nodeHelp": "矢印キーでサイズを変更します。Shift を押しながら操作すると変更幅が大きくなります。Home または Enter で内容が収まる最小サイズに縮小します。",
|
||||
"resize.size": "幅 {{width}} ピクセル、高さ {{height}} ピクセル",
|
||||
"resize.width": "幅:{{width}}ピクセル",
|
||||
"resourceAccessToken.accessibleResources": "Accessible Resources",
|
||||
"resourceAccessToken.actions": "Actions",
|
||||
"resourceAccessToken.app": "App",
|
||||
"resourceAccessToken.appCount": "{{count}} App",
|
||||
"resourceAccessToken.appsCount": "{{count}} Apps",
|
||||
"resourceAccessToken.appsSection": "Apps",
|
||||
"resourceAccessToken.copied": "Access token copied",
|
||||
"resourceAccessToken.createButton": "Create new token",
|
||||
"resourceAccessToken.createTitle": "Create access token",
|
||||
"resourceAccessToken.created": "Access token created",
|
||||
"resourceAccessToken.createdColumn": "Created",
|
||||
"resourceAccessToken.createdToken": "Save your token",
|
||||
"resourceAccessToken.createdTokenDescription": "Save this access token in a secure place—you won’t be able to view it again. Anyone who has it can use Difyctl to discover and access the resource authorized by this token. If you lose it, create a new token and revoke the old one.",
|
||||
"resourceAccessToken.deleteDescription": "The Access token will be disabled immediately. Calls using this token will be rejected, which may cause the task that is using the access token to interrupt or fail. Once deleted, you will no longer be able to view or modify this Access token.",
|
||||
"resourceAccessToken.deleteTitle": "Delete access token ?",
|
||||
"resourceAccessToken.deleted": "Access token deleted",
|
||||
"resourceAccessToken.done": "Done",
|
||||
"resourceAccessToken.editTitle": "Edit access token",
|
||||
"resourceAccessToken.empty": "No access tokens yet",
|
||||
"resourceAccessToken.emptyDescription": "Create an access token for automated tasks and external systems to discover and access selected resource through Difyctl.",
|
||||
"resourceAccessToken.knowledge": "Knowledge",
|
||||
"resourceAccessToken.knowledgeBasesSection": "Knowledge bases",
|
||||
"resourceAccessToken.knowledgeCount": "{{count}} Knowledge",
|
||||
"resourceAccessToken.knowledgesCount": "{{count}} Knowledges",
|
||||
"resourceAccessToken.name": "Name",
|
||||
"resourceAccessToken.namePlaceholder": "Enter a name",
|
||||
"resourceAccessToken.next": "Next",
|
||||
"resourceAccessToken.noAccessibleResources": "No accessible resources",
|
||||
"resourceAccessToken.noResourceSearchResults": "No matching resources",
|
||||
"resourceAccessToken.noResources": "No API-enabled resources available",
|
||||
"resourceAccessToken.previous": "Previous",
|
||||
"resourceAccessToken.resources": "Resources",
|
||||
"resourceAccessToken.searchPlaceholder": "Search tokens...",
|
||||
"resourceAccessToken.searchResourcesPlaceholder": "Search apps or knowledge bases...",
|
||||
"resourceAccessToken.tabAll": "All",
|
||||
"resourceAccessToken.tabApps": "Apps",
|
||||
"resourceAccessToken.tabKnowledgeBases": "Knowledge bases",
|
||||
"resourceAccessToken.token": "Token",
|
||||
"resourceAccessToken.total": "{{total}} total",
|
||||
"resourceAccessToken.trackingId": "Tracking ID",
|
||||
"settings.accessToken": "Access Tokens",
|
||||
"settings.accessTokenDescription": "Create service API tokens that grant access only to selected apps and knowledge bases.",
|
||||
"settings.agentStrategy": "エージェント戦略",
|
||||
"settings.billing": "請求",
|
||||
"settings.customEndpoint": "カスタム API",
|
||||
|
||||
@@ -189,6 +189,11 @@
|
||||
"skillManagement.errors.nameConflict": "「{{name}}」という名前のスキルはすでに存在します。SKILL.md で名前を変更してから、もう一度インポートしてください。",
|
||||
"skillManagement.errors.workspaceLimit": "ワークスペースに追加できるスキルは 500 個までです。スキルを 1 つ削除してから、新しいスキルを追加してください。",
|
||||
"skillManagement.import": "インポート",
|
||||
"skillManagement.importDialog.browse": "参照",
|
||||
"skillManagement.importDialog.description": "SKILL.md を含む .zip および .skill パッケージに対応しています。",
|
||||
"skillManagement.importDialog.dropzone": "ファイルをドラッグ&ドロップするか、<browse>参照</browse>",
|
||||
"skillManagement.importDialog.invalidFile": ".zip または .skill パッケージを1つ選択してください。",
|
||||
"skillManagement.importDialog.title": "スキルをインポート",
|
||||
"skillManagement.importFailed": "スキルのインポートに失敗しました。",
|
||||
"skillManagement.importSuccess": "Skill がインポートされました。",
|
||||
"skillManagement.listLabel": "ワークスペースのスキル",
|
||||
|
||||
@@ -545,6 +545,48 @@
|
||||
"resize.nodeHelp": "방향키로 크기를 조절합니다. Shift를 누르면 조절 폭이 커집니다. Home 또는 Enter로 내용에 맞는 최소 크기로 줄입니다.",
|
||||
"resize.size": "너비 {{width}}픽셀, 높이 {{height}}픽셀",
|
||||
"resize.width": "너비: {{width}}픽셀",
|
||||
"resourceAccessToken.accessibleResources": "Accessible Resources",
|
||||
"resourceAccessToken.actions": "Actions",
|
||||
"resourceAccessToken.app": "App",
|
||||
"resourceAccessToken.appCount": "{{count}} App",
|
||||
"resourceAccessToken.appsCount": "{{count}} Apps",
|
||||
"resourceAccessToken.appsSection": "Apps",
|
||||
"resourceAccessToken.copied": "Access token copied",
|
||||
"resourceAccessToken.createButton": "Create new token",
|
||||
"resourceAccessToken.createTitle": "Create access token",
|
||||
"resourceAccessToken.created": "Access token created",
|
||||
"resourceAccessToken.createdColumn": "Created",
|
||||
"resourceAccessToken.createdToken": "Save your token",
|
||||
"resourceAccessToken.createdTokenDescription": "Save this access token in a secure place—you won’t be able to view it again. Anyone who has it can use Difyctl to discover and access the resource authorized by this token. If you lose it, create a new token and revoke the old one.",
|
||||
"resourceAccessToken.deleteDescription": "The Access token will be disabled immediately. Calls using this token will be rejected, which may cause the task that is using the access token to interrupt or fail. Once deleted, you will no longer be able to view or modify this Access token.",
|
||||
"resourceAccessToken.deleteTitle": "Delete access token ?",
|
||||
"resourceAccessToken.deleted": "Access token deleted",
|
||||
"resourceAccessToken.done": "Done",
|
||||
"resourceAccessToken.editTitle": "Edit access token",
|
||||
"resourceAccessToken.empty": "No access tokens yet",
|
||||
"resourceAccessToken.emptyDescription": "Create an access token for automated tasks and external systems to discover and access selected resource through Difyctl.",
|
||||
"resourceAccessToken.knowledge": "Knowledge",
|
||||
"resourceAccessToken.knowledgeBasesSection": "Knowledge bases",
|
||||
"resourceAccessToken.knowledgeCount": "{{count}} Knowledge",
|
||||
"resourceAccessToken.knowledgesCount": "{{count}} Knowledges",
|
||||
"resourceAccessToken.name": "Name",
|
||||
"resourceAccessToken.namePlaceholder": "Enter a name",
|
||||
"resourceAccessToken.next": "Next",
|
||||
"resourceAccessToken.noAccessibleResources": "No accessible resources",
|
||||
"resourceAccessToken.noResourceSearchResults": "No matching resources",
|
||||
"resourceAccessToken.noResources": "No API-enabled resources available",
|
||||
"resourceAccessToken.previous": "Previous",
|
||||
"resourceAccessToken.resources": "Resources",
|
||||
"resourceAccessToken.searchPlaceholder": "Search tokens...",
|
||||
"resourceAccessToken.searchResourcesPlaceholder": "Search apps or knowledge bases...",
|
||||
"resourceAccessToken.tabAll": "All",
|
||||
"resourceAccessToken.tabApps": "Apps",
|
||||
"resourceAccessToken.tabKnowledgeBases": "Knowledge bases",
|
||||
"resourceAccessToken.token": "Token",
|
||||
"resourceAccessToken.total": "{{total}} total",
|
||||
"resourceAccessToken.trackingId": "Tracking ID",
|
||||
"settings.accessToken": "Access Tokens",
|
||||
"settings.accessTokenDescription": "Create service API tokens that grant access only to selected apps and knowledge bases.",
|
||||
"settings.agentStrategy": "Agent strategy",
|
||||
"settings.billing": "청구",
|
||||
"settings.customEndpoint": "사용자 지정 엔드포인트",
|
||||
|
||||
@@ -189,6 +189,11 @@
|
||||
"skillManagement.errors.nameConflict": "\"{{name}}\"라는 스킬이 이미 존재합니다. SKILL.md에서 이름을 바꾸고 다시 가져옵니다.",
|
||||
"skillManagement.errors.workspaceLimit": "작업 영역이 스킬 한도인 500개에 도달했습니다. 새로운 스킬을 추가하려면 스킬을 삭제하세요.",
|
||||
"skillManagement.import": "가져오기",
|
||||
"skillManagement.importDialog.browse": "찾아보기",
|
||||
"skillManagement.importDialog.description": "SKILL.md가 포함된 .zip 및 .skill 패키지를 지원합니다.",
|
||||
"skillManagement.importDialog.dropzone": "파일을 끌어다 놓거나 <browse>찾아보기</browse>",
|
||||
"skillManagement.importDialog.invalidFile": ".zip 또는 .skill 패키지 하나를 선택하세요.",
|
||||
"skillManagement.importDialog.title": "스킬 가져오기",
|
||||
"skillManagement.importFailed": "스킬을 가져오지 못했습니다.",
|
||||
"skillManagement.importSuccess": "Skill 가져왔습니다.",
|
||||
"skillManagement.listLabel": "작업 공간 기술",
|
||||
|
||||
@@ -545,6 +545,48 @@
|
||||
"resize.nodeHelp": "ໃຊ້ປຸ່ມລູກສອນເພື່ອປັບຂະໜາດ. ກົດ Shift ຄ້າງໄວ້ເພື່ອປັບເທື່ອລະຫຼາຍຂຶ້ນ. Home ຫຼື Enter ຈະຫຍໍ້ໃຫ້ພໍດີກັບເນື້ອຫາ.",
|
||||
"resize.size": "ກວ້າງ {{width}} ພິກເຊລ, ສູງ {{height}} ພິກເຊລ",
|
||||
"resize.width": "ຄວາມກວ້າງ: {{width}} ພິກເຊລ",
|
||||
"resourceAccessToken.accessibleResources": "Accessible Resources",
|
||||
"resourceAccessToken.actions": "Actions",
|
||||
"resourceAccessToken.app": "App",
|
||||
"resourceAccessToken.appCount": "{{count}} App",
|
||||
"resourceAccessToken.appsCount": "{{count}} Apps",
|
||||
"resourceAccessToken.appsSection": "Apps",
|
||||
"resourceAccessToken.copied": "Access token copied",
|
||||
"resourceAccessToken.createButton": "Create new token",
|
||||
"resourceAccessToken.createTitle": "Create access token",
|
||||
"resourceAccessToken.created": "Access token created",
|
||||
"resourceAccessToken.createdColumn": "Created",
|
||||
"resourceAccessToken.createdToken": "Save your token",
|
||||
"resourceAccessToken.createdTokenDescription": "Save this access token in a secure place—you won’t be able to view it again. Anyone who has it can use Difyctl to discover and access the resource authorized by this token. If you lose it, create a new token and revoke the old one.",
|
||||
"resourceAccessToken.deleteDescription": "The Access token will be disabled immediately. Calls using this token will be rejected, which may cause the task that is using the access token to interrupt or fail. Once deleted, you will no longer be able to view or modify this Access token.",
|
||||
"resourceAccessToken.deleteTitle": "Delete access token ?",
|
||||
"resourceAccessToken.deleted": "Access token deleted",
|
||||
"resourceAccessToken.done": "Done",
|
||||
"resourceAccessToken.editTitle": "Edit access token",
|
||||
"resourceAccessToken.empty": "No access tokens yet",
|
||||
"resourceAccessToken.emptyDescription": "Create an access token for automated tasks and external systems to discover and access selected resource through Difyctl.",
|
||||
"resourceAccessToken.knowledge": "Knowledge",
|
||||
"resourceAccessToken.knowledgeBasesSection": "Knowledge bases",
|
||||
"resourceAccessToken.knowledgeCount": "{{count}} Knowledge",
|
||||
"resourceAccessToken.knowledgesCount": "{{count}} Knowledges",
|
||||
"resourceAccessToken.name": "Name",
|
||||
"resourceAccessToken.namePlaceholder": "Enter a name",
|
||||
"resourceAccessToken.next": "Next",
|
||||
"resourceAccessToken.noAccessibleResources": "No accessible resources",
|
||||
"resourceAccessToken.noResourceSearchResults": "No matching resources",
|
||||
"resourceAccessToken.noResources": "No API-enabled resources available",
|
||||
"resourceAccessToken.previous": "Previous",
|
||||
"resourceAccessToken.resources": "Resources",
|
||||
"resourceAccessToken.searchPlaceholder": "Search tokens...",
|
||||
"resourceAccessToken.searchResourcesPlaceholder": "Search apps or knowledge bases...",
|
||||
"resourceAccessToken.tabAll": "All",
|
||||
"resourceAccessToken.tabApps": "Apps",
|
||||
"resourceAccessToken.tabKnowledgeBases": "Knowledge bases",
|
||||
"resourceAccessToken.token": "Token",
|
||||
"resourceAccessToken.total": "{{total}} total",
|
||||
"resourceAccessToken.trackingId": "Tracking ID",
|
||||
"settings.accessToken": "Access Tokens",
|
||||
"settings.accessTokenDescription": "Create service API tokens that grant access only to selected apps and knowledge bases.",
|
||||
"settings.agentStrategy": "ກົນລະຍຸດຕົວແທນ",
|
||||
"settings.billing": "ການຊຳລະເງິນ",
|
||||
"settings.customEndpoint": "ຈຸດເຊື່ອມຕໍ່ແບບກຳນົດເອງ",
|
||||
|
||||
@@ -189,6 +189,11 @@
|
||||
"skillManagement.errors.nameConflict": "ທັກສະທີ່ມີຊື່ວ່າ \"{{name}}\" ມີຢູ່ແລ້ວ. ປ່ຽນຊື່ມັນຢູ່ໃນ SKILL.md ແລະນໍາເຂົ້າອີກເທື່ອຫນຶ່ງ.",
|
||||
"skillManagement.errors.workspaceLimit": "ພື້ນທີ່ເຮັດວຽກຂອງທ່ານຮອດຂີດຈຳກັດຂອງ 500 ທັກສະແລ້ວ. ລຶບທັກສະເພື່ອເພີ່ມອັນໃໝ່.",
|
||||
"skillManagement.import": "ນຳເຂົ້າ",
|
||||
"skillManagement.importDialog.browse": "ເລືອກໄຟລ໌",
|
||||
"skillManagement.importDialog.description": "ຮອງຮັບແພັກເກັດ .zip ແລະ .skill ທີ່ມີ SKILL.md.",
|
||||
"skillManagement.importDialog.dropzone": "ລາກແລະວາງໄຟລ໌ ຫຼື <browse>ເລືອກໄຟລ໌</browse>",
|
||||
"skillManagement.importDialog.invalidFile": "ເລືອກແພັກເກັດ .zip ຫຼື .skill ໜຶ່ງໄຟລ໌.",
|
||||
"skillManagement.importDialog.title": "ນຳເຂົ້າທັກສະ",
|
||||
"skillManagement.importFailed": "ລົ້ມເຫລວໃນການນໍາເຂົ້າທັກສະ.",
|
||||
"skillManagement.importSuccess": "Skill ນໍາເຂົ້າ.",
|
||||
"skillManagement.listLabel": "ທັກສະພື້ນທີ່ເຮັດວຽກ",
|
||||
|
||||
@@ -545,6 +545,48 @@
|
||||
"resize.nodeHelp": "Gebruik de pijltjestoetsen om het formaat te wijzigen. Houd Shift ingedrukt voor grotere stappen. Home of Enter verkleint het formaat tot de inhoud past.",
|
||||
"resize.size": "{{width}} pixels breed, {{height}} pixels hoog",
|
||||
"resize.width": "Breedte: {{width}} pixels",
|
||||
"resourceAccessToken.accessibleResources": "Accessible Resources",
|
||||
"resourceAccessToken.actions": "Actions",
|
||||
"resourceAccessToken.app": "App",
|
||||
"resourceAccessToken.appCount": "{{count}} App",
|
||||
"resourceAccessToken.appsCount": "{{count}} Apps",
|
||||
"resourceAccessToken.appsSection": "Apps",
|
||||
"resourceAccessToken.copied": "Access token copied",
|
||||
"resourceAccessToken.createButton": "Create new token",
|
||||
"resourceAccessToken.createTitle": "Create access token",
|
||||
"resourceAccessToken.created": "Access token created",
|
||||
"resourceAccessToken.createdColumn": "Created",
|
||||
"resourceAccessToken.createdToken": "Save your token",
|
||||
"resourceAccessToken.createdTokenDescription": "Save this access token in a secure place—you won’t be able to view it again. Anyone who has it can use Difyctl to discover and access the resource authorized by this token. If you lose it, create a new token and revoke the old one.",
|
||||
"resourceAccessToken.deleteDescription": "The Access token will be disabled immediately. Calls using this token will be rejected, which may cause the task that is using the access token to interrupt or fail. Once deleted, you will no longer be able to view or modify this Access token.",
|
||||
"resourceAccessToken.deleteTitle": "Delete access token ?",
|
||||
"resourceAccessToken.deleted": "Access token deleted",
|
||||
"resourceAccessToken.done": "Done",
|
||||
"resourceAccessToken.editTitle": "Edit access token",
|
||||
"resourceAccessToken.empty": "No access tokens yet",
|
||||
"resourceAccessToken.emptyDescription": "Create an access token for automated tasks and external systems to discover and access selected resource through Difyctl.",
|
||||
"resourceAccessToken.knowledge": "Knowledge",
|
||||
"resourceAccessToken.knowledgeBasesSection": "Knowledge bases",
|
||||
"resourceAccessToken.knowledgeCount": "{{count}} Knowledge",
|
||||
"resourceAccessToken.knowledgesCount": "{{count}} Knowledges",
|
||||
"resourceAccessToken.name": "Name",
|
||||
"resourceAccessToken.namePlaceholder": "Enter a name",
|
||||
"resourceAccessToken.next": "Next",
|
||||
"resourceAccessToken.noAccessibleResources": "No accessible resources",
|
||||
"resourceAccessToken.noResourceSearchResults": "No matching resources",
|
||||
"resourceAccessToken.noResources": "No API-enabled resources available",
|
||||
"resourceAccessToken.previous": "Previous",
|
||||
"resourceAccessToken.resources": "Resources",
|
||||
"resourceAccessToken.searchPlaceholder": "Search tokens...",
|
||||
"resourceAccessToken.searchResourcesPlaceholder": "Search apps or knowledge bases...",
|
||||
"resourceAccessToken.tabAll": "All",
|
||||
"resourceAccessToken.tabApps": "Apps",
|
||||
"resourceAccessToken.tabKnowledgeBases": "Knowledge bases",
|
||||
"resourceAccessToken.token": "Token",
|
||||
"resourceAccessToken.total": "{{total}} total",
|
||||
"resourceAccessToken.trackingId": "Tracking ID",
|
||||
"settings.accessToken": "Access Tokens",
|
||||
"settings.accessTokenDescription": "Create service API tokens that grant access only to selected apps and knowledge bases.",
|
||||
"settings.agentStrategy": "Agent strategy",
|
||||
"settings.billing": "Billing",
|
||||
"settings.customEndpoint": "Aangepast eindpunt",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user