Compare commits
393 Commits
v0.5.2
...
v1.0.0-rc.
Author | SHA1 | Date | |
---|---|---|---|
99cb24fa30 | |||
bd8462b2b3 | |||
0857e4ea68 | |||
9ac81dd138 | |||
3e48bc8048 | |||
33cd0544a1 | |||
f394b17537 | |||
755fdacf60 | |||
9e7db8193a | |||
4336e89ba2 | |||
5539c2802b | |||
c8262bed6c | |||
03c2ab56af | |||
0124f71cea | |||
6b06f37626 | |||
34ffb1a679 | |||
0c36918cb8 | |||
1a0dc70670 | |||
aa0b27ebc8 | |||
44f56460ab | |||
f3f79c4193 | |||
4dc9ed92cd | |||
2c97755868 | |||
552d728049 | |||
8fb0fe7fb5 | |||
efb631e906 | |||
a7b3252096 | |||
b15b070487 | |||
ff00a69025 | |||
4dcf352896 | |||
cb286d827d | |||
6a5defe9d6 | |||
d97cbbb183 | |||
200242aa67 | |||
ba62afc034 | |||
52421b1598 | |||
b34cd23d83 | |||
a7d45c5347 | |||
4b15bd734e | |||
522ecd2518 | |||
8f01a01a46 | |||
ebc5272023 | |||
c06029f040 | |||
2fbe1e5664 | |||
ae62cac34d | |||
f306c82a4f | |||
9b060cb7e7 | |||
c5a38d14ad | |||
3937abb17b | |||
f276c979fa | |||
a49f87c55f | |||
b74e4f90c1 | |||
8511c09c63 | |||
b359b8f1cc | |||
220cabfb5b | |||
baa3378ff7 | |||
b64ae1399a | |||
0ac2554545 | |||
6c54732cd1 | |||
85f5f03be9 | |||
784d91ab75 | |||
9d6d93ab39 | |||
f11a295ac2 | |||
ff035d25ba | |||
643a6051b2 | |||
1cf274389c | |||
4098ac824e | |||
93bb880e8e | |||
2ddb47f4df | |||
0aab1a0e5d | |||
f89100d721 | |||
3c0b3369b8 | |||
d3dcab3445 | |||
acb33080f3 | |||
080929faed | |||
966e1d9ce3 | |||
1ee13c6859 | |||
c196cbc7d5 | |||
4b7c494163 | |||
86b4f4aa9f | |||
e3c2dc8441 | |||
85adb885fe | |||
a44e3e23d9 | |||
340025002b | |||
fc20673706 | |||
3df3405ad5 | |||
c4b26cac38 | |||
a7c49a9ac0 | |||
5d703f563a | |||
c571720d42 | |||
a3943f5b2d | |||
80548aaf2c | |||
da14b9b7e5 | |||
7e35d3a319 | |||
dba5067d51 | |||
20e3212c2e | |||
d04d0d0e37 | |||
01f1b961e1 | |||
06b8f32a94 | |||
8eac931592 | |||
8d6ad68d47 | |||
c356bc03e5 | |||
da94907913 | |||
f5edcff24b | |||
c98e717f5b | |||
a5bc443db9 | |||
faa6fcf31c | |||
0be4d8c906 | |||
63ebd6f8ea | |||
972b3a3106 | |||
f057cd0078 | |||
9876fb27a4 | |||
e63c8dde09 | |||
d4e575774c | |||
db09ac2c5c | |||
ea7ea0ac24 | |||
80304070e7 | |||
7c31fd444c | |||
00e2a6fd96 | |||
768af5b096 | |||
61dd989df4 | |||
647726fc2d | |||
6db8611a58 | |||
e12c83137d | |||
89d1812c07 | |||
e53ced8996 | |||
f9e5978a08 | |||
0297e9aa2d | |||
c4e72f35c3 | |||
7179cde44a | |||
ba286a2e9c | |||
c2541f0b43 | |||
b6322fb532 | |||
32b321dcfd | |||
cc0884b07e | |||
133c701194 | |||
04f5c7ff1a | |||
81aa4d3d4e | |||
a013edcfe3 | |||
90bc42ea71 | |||
a7057f9e9f | |||
87e251d6b0 | |||
b6271938b3 | |||
a113d82e9b | |||
d513e89438 | |||
075f51f745 | |||
ecf77e0774 | |||
61d01a53eb | |||
8f8cd81c14 | |||
5dd045e080 | |||
2ad51a3d42 | |||
51ed88d5aa | |||
1de9973af3 | |||
324d6899c0 | |||
ad54dd14c8 | |||
8010f54747 | |||
87deeca110 | |||
6920313b77 | |||
b1051418c6 | |||
5f3ce82b37 | |||
5313b5a3e1 | |||
59b56d9374 | |||
f9c1401437 | |||
2502218fa1 | |||
89dca1b1ef | |||
cf640123b8 | |||
e9856115a8 | |||
abe715acce | |||
0a92ba03a8 | |||
5dca1373fc | |||
9b6ba2a5bb | |||
6ac5ff20cd | |||
6366379c34 | |||
399047ef30 | |||
9e887f604d | |||
eac6cb1df3 | |||
d6bb852720 | |||
c3f32eb7f4 | |||
c78f8c6e42 | |||
2f35d7cb2d | |||
4b5294f806 | |||
d6f9dd8145 | |||
9db11c110e | |||
1dd6b85128 | |||
277f262fb1 | |||
d9e52579eb | |||
078c18c61c | |||
425aaab954 | |||
b90234c1de | |||
444e77bd8a | |||
7d696bf6d1 | |||
dae12720cd | |||
acb252ac4f | |||
8216768b7e | |||
8037f054af | |||
bc222bfa8d | |||
2264e0cf9f | |||
24544be0e6 | |||
1fea5102cf | |||
ba77def895 | |||
8cd414f8e9 | |||
50f3134bfa | |||
817e7ff87a | |||
bb97cccb31 | |||
558be11808 | |||
2f18894e1a | |||
270b61c08f | |||
410599f21a | |||
73a16941b2 | |||
9df080d379 | |||
36008483eb | |||
28301ebb48 | |||
a8723c2d94 | |||
c1885823a1 | |||
cbfdc03bb9 | |||
8c05ca4338 | |||
d96253798d | |||
a695d8ed36 | |||
c3baef9ddc | |||
1cc9736aa0 | |||
cf7e3813fe | |||
0482491352 | |||
dfc42476c4 | |||
5cfd6a28bf | |||
35100dd533 | |||
797164bd39 | |||
461cf2f45e | |||
291c7a88d9 | |||
a0692aae66 | |||
dc821e7221 | |||
348be0b76e | |||
0cf3a937da | |||
d8795db70f | |||
a03b9678c7 | |||
b91273605e | |||
7c2858e1ae | |||
70bdbcfb21 | |||
a5957aa6db | |||
b12f316991 | |||
618fbb19c5 | |||
075633ea10 | |||
ba22186908 | |||
c10e1968d1 | |||
d0d11f2c98 | |||
8dde790e51 | |||
233e10b264 | |||
d2ae38ff6b | |||
3441ff4821 | |||
2e25347de0 | |||
4a05cf99f2 | |||
e0e4a1af8f | |||
769b474bdc | |||
d51d180a29 | |||
15ca4fe7ac | |||
e82bac9385 | |||
df0c24354a | |||
567636ff2b | |||
ebc0feb259 | |||
9d2c6bcc37 | |||
d522eae296 | |||
2ba0694597 | |||
c6b516a054 | |||
d482183b4f | |||
2558331d8f | |||
482fb21fe9 | |||
a4408bff0e | |||
970bd9afb0 | |||
55a0c7f800 | |||
9cdc815312 | |||
856cad8697 | |||
6746b9dc58 | |||
4444c72042 | |||
26aa00e20b | |||
0602b2475a | |||
9e8eff134b | |||
e55c48865a | |||
f98a61ba94 | |||
0fc3497a5b | |||
af9bcade33 | |||
2fd1224255 | |||
55974b1e6c | |||
c727cc2476 | |||
9b4e58f8c7 | |||
3b0678ee7a | |||
f4a9e7bed7 | |||
2a59f0339a | |||
57caafec59 | |||
38dfbb9012 | |||
f0121e2a01 | |||
0e2940b58b | |||
344fcab972 | |||
8b2a8df5b0 | |||
9f52377a3a | |||
b582ccd0c9 | |||
ca199c3486 | |||
2791dc98ac | |||
ed02309f3b | |||
db485ba0a9 | |||
a291635edd | |||
807481eb7f | |||
d395221ea1 | |||
a79b1e80d8 | |||
ad65c66be4 | |||
3ef8b785c3 | |||
cd45d5a5b5 | |||
4861136fdd | |||
2746607368 | |||
8ee002074d | |||
76031a9126 | |||
efb7910f3a | |||
90e7e48f66 | |||
2efdec60fe | |||
714e01ee4e | |||
bb45bb3f36 | |||
101aa6a10f | |||
399364af01 | |||
660f7fd955 | |||
43be41b8a5 | |||
92eaa3c613 | |||
17fd86726d | |||
393574d57d | |||
10c94b0128 | |||
8aebafd531 | |||
70602b8c6c | |||
832b3945d9 | |||
7eeef74f4c | |||
c708585a0b | |||
6393531ac5 | |||
549dad7261 | |||
b883905f8a | |||
91e0fae1d9 | |||
9a2618f42b | |||
6bb99ed3cc | |||
6e11c28d3e | |||
f6f564913a | |||
40deb997ea | |||
59118109bd | |||
971eb4e8f7 | |||
1c70d9484e | |||
6a8c07f93a | |||
0ef6a6038a | |||
dd521103c9 | |||
b04ea04062 | |||
1774e525b3 | |||
d502e3ce74 | |||
80e52829fa | |||
c61aa8020a | |||
d2d63836d4 | |||
e0ad25b2c6 | |||
cc669f1be0 | |||
5bf86601e6 | |||
b7484363dc | |||
5264dc9d8a | |||
8649e562dc | |||
905b962e0b | |||
07c863b251 | |||
69f2c7ad89 | |||
e2c7b8c7b9 | |||
c6821a7090 | |||
b1125f3727 | |||
c7af8d6afa | |||
1025d8a2ed | |||
3f3d7a4c58 | |||
80f0af8723 | |||
730cff1148 | |||
0e3481b593 | |||
abacc9af8b | |||
d837cbd0ff | |||
3f7abce427 | |||
b6bcc3cda6 | |||
07d9436e1e | |||
5c1c238453 | |||
02fb415260 | |||
d866268a7a | |||
98d73e81c0 | |||
47821879fa | |||
7c16b1e00f | |||
29043f63b6 | |||
87d626cd1d | |||
201cf83afe | |||
35de611fd1 | |||
5c02bb98bf | |||
c1f915ae31 | |||
faae146a86 | |||
4a6c6b4b2a | |||
fafacc92eb | |||
b5f5ae2483 | |||
cdfb015638 | |||
435fe04ab3 | |||
4e73882bf1 | |||
f2d9b29baa | |||
eaf9113c92 | |||
194571e132 |
26
.github/dependabot.yml
vendored
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
commit-message:
|
||||||
|
prefix: "chore"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "monthly"
|
||||||
|
- package-ecosystem: npm
|
||||||
|
commit-message:
|
||||||
|
prefix: "chore"
|
||||||
|
directory: "/frontend/web"
|
||||||
|
schedule:
|
||||||
|
interval: "monthly"
|
||||||
|
- package-ecosystem: npm
|
||||||
|
commit-message:
|
||||||
|
prefix: "chore"
|
||||||
|
directory: "/frontend/extension"
|
||||||
|
schedule:
|
||||||
|
interval: "monthly"
|
||||||
|
- package-ecosystem: "gomod"
|
||||||
|
commit-message:
|
||||||
|
prefix: "chore"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "monthly"
|
16
.github/workflows/backend-tests.yml
vendored
@ -12,10 +12,10 @@ jobs:
|
|||||||
go-static-checks:
|
go-static-checks:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-go@v3
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: 1.21
|
go-version: 1.23
|
||||||
check-latest: true
|
check-latest: true
|
||||||
cache: true
|
cache: true
|
||||||
- name: Verify go.mod is tidy
|
- name: Verify go.mod is tidy
|
||||||
@ -23,19 +23,19 @@ jobs:
|
|||||||
go mod tidy
|
go mod tidy
|
||||||
git diff --exit-code
|
git diff --exit-code
|
||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v3
|
uses: golangci/golangci-lint-action@v6
|
||||||
with:
|
with:
|
||||||
version: v1.54.1
|
version: v1.61.0
|
||||||
args: --verbose --timeout=3m
|
args: --verbose --timeout=3m
|
||||||
skip-cache: true
|
skip-cache: true
|
||||||
|
|
||||||
go-tests:
|
go-tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-go@v3
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: 1.21
|
go-version: 1.23
|
||||||
check-latest: true
|
check-latest: true
|
||||||
cache: true
|
cache: true
|
||||||
- name: Run all tests
|
- name: Run all tests
|
||||||
|
@ -1,44 +1,85 @@
|
|||||||
name: build-and-push-release-image
|
name: build-and-push-stable-image
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
tags:
|
||||||
# Run on pushing branches like `release/1.0.0`
|
# Match stable and rc versions, such as 'v1.0.0' or 'v0.23.0-rc.0'
|
||||||
- "release/*.*.*"
|
# - "v*.*.*"
|
||||||
|
# - "v*.*.*-rc.*"
|
||||||
|
- "v*.*.*-e"
|
||||||
|
- "v*.*.*-rc.*-e"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push-release-image:
|
build-and-push-stable-image:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: Extract build args
|
- name: Extract build args
|
||||||
# Extract version from branch name
|
# Extract version number and check if it's an rc version
|
||||||
# Example: branch name `release/1.0.0` sets up env.VERSION=1.0.0
|
|
||||||
run: |
|
run: |
|
||||||
echo "VERSION=${GITHUB_REF_NAME#release/}" >> $GITHUB_ENV
|
if [[ "${GITHUB_REF_NAME}" =~ -rc ]]; then
|
||||||
|
echo "PRE_RELEASE=true" >> $GITHUB_ENV
|
||||||
|
else
|
||||||
|
echo "PRE_RELEASE=false" >> $GITHUB_ENV
|
||||||
|
fi
|
||||||
|
echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: yourselfhosted
|
username: aykhans
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v3
|
||||||
with:
|
with:
|
||||||
install: true
|
install: true
|
||||||
|
version: v0.9.1
|
||||||
|
|
||||||
|
# Metadata for stable versions
|
||||||
|
- name: Docker meta for stable
|
||||||
|
id: meta-stable
|
||||||
|
if: env.PRE_RELEASE == 'false'
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
aykhans/slash
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}},value=${{ env.VERSION }}
|
||||||
|
type=raw,value=stable
|
||||||
|
flavor: |
|
||||||
|
latest=true
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.version=${{ env.VERSION }}
|
||||||
|
|
||||||
|
# Metadata for rc versions
|
||||||
|
- name: Docker meta for rc
|
||||||
|
id: meta-rc
|
||||||
|
if: env.PRE_RELEASE == 'true'
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
aykhans/slash
|
||||||
|
tags: |
|
||||||
|
type=raw,value=${{ env.VERSION }}
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.version=${{ env.VERSION }}
|
||||||
|
|
||||||
- name: Build and Push
|
- name: Build and Push
|
||||||
id: docker_build
|
id: docker_build
|
||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: ./
|
context: ./
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: yourselfhosted/slash:latest, yourselfhosted/slash:${{ env.VERSION }}
|
tags: ${{ steps.meta-stable.outputs.tags || steps.meta-rc.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta-stable.outputs.labels || steps.meta-rc.outputs.labels }}
|
||||||
|
10
.github/workflows/build-and-push-test-image.yml
vendored
@ -8,27 +8,27 @@ jobs:
|
|||||||
build-and-push-test-image:
|
build-and-push-test-image:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: yourselfhosted
|
username: yourselfhosted
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v3
|
||||||
with:
|
with:
|
||||||
install: true
|
install: true
|
||||||
version: v0.9.1
|
version: v0.9.1
|
||||||
|
|
||||||
- name: Build and Push
|
- name: Build and Push
|
||||||
id: docker_build
|
id: docker_build
|
||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: ./
|
context: ./
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
|
33
.github/workflows/build-artifacts.yml
vendored
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
name: Build artifacts
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "*"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
goreleaser:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: 1.23
|
||||||
|
check-latest: true
|
||||||
|
cache: true
|
||||||
|
- name: Run GoReleaser
|
||||||
|
uses: goreleaser/goreleaser-action@v6
|
||||||
|
with:
|
||||||
|
# either 'goreleaser' (default) or 'goreleaser-pro'
|
||||||
|
distribution: goreleaser
|
||||||
|
# 'latest', 'nightly', or a semver
|
||||||
|
version: latest
|
||||||
|
args: release --clean
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
20
.github/workflows/extension-test.yml
vendored
@ -14,19 +14,17 @@ jobs:
|
|||||||
eslint-checks:
|
eslint-checks:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- uses: pnpm/action-setup@v2.2.4
|
- uses: pnpm/action-setup@v4.0.0
|
||||||
with:
|
with:
|
||||||
version: 8
|
version: 9
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "18"
|
node-version: "18"
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
cache-dependency-path: "frontend/extension/pnpm-lock.yaml"
|
cache-dependency-path: "frontend/extension/pnpm-lock.yaml"
|
||||||
- run: pnpm install
|
- run: pnpm install
|
||||||
working-directory: frontend/extension
|
working-directory: frontend/extension
|
||||||
- run: pnpm type-gen
|
|
||||||
working-directory: frontend/extension
|
|
||||||
- name: Run eslint check
|
- name: Run eslint check
|
||||||
run: pnpm lint
|
run: pnpm lint
|
||||||
working-directory: frontend/extension
|
working-directory: frontend/extension
|
||||||
@ -34,19 +32,17 @@ jobs:
|
|||||||
extension-build:
|
extension-build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- uses: pnpm/action-setup@v2.2.4
|
- uses: pnpm/action-setup@v4.0.0
|
||||||
with:
|
with:
|
||||||
version: 8
|
version: 9
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "18"
|
node-version: "18"
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
cache-dependency-path: "frontend/extension/pnpm-lock.yaml"
|
cache-dependency-path: "frontend/extension/pnpm-lock.yaml"
|
||||||
- run: pnpm install
|
- run: pnpm install
|
||||||
working-directory: frontend/extension
|
working-directory: frontend/extension
|
||||||
- run: pnpm type-gen
|
|
||||||
working-directory: frontend/extension
|
|
||||||
- name: Run extension build
|
- name: Run extension build
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
working-directory: frontend/extension
|
working-directory: frontend/extension
|
||||||
|
23
.github/workflows/frontend-test.yml
vendored
@ -14,39 +14,38 @@ jobs:
|
|||||||
eslint-checks:
|
eslint-checks:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- uses: pnpm/action-setup@v2.2.4
|
- uses: pnpm/action-setup@v4.0.0
|
||||||
with:
|
with:
|
||||||
version: 8
|
version: 9
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "18"
|
node-version: "18"
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
cache-dependency-path: "frontend/web/pnpm-lock.yaml"
|
cache-dependency-path: "frontend/web/pnpm-lock.yaml"
|
||||||
- run: pnpm install
|
- run: pnpm install
|
||||||
working-directory: frontend/web
|
working-directory: frontend/web
|
||||||
- run: pnpm type-gen
|
|
||||||
working-directory: frontend/web
|
|
||||||
- name: Run eslint check
|
- name: Run eslint check
|
||||||
run: pnpm lint
|
run: pnpm lint
|
||||||
working-directory: frontend/web
|
working-directory: frontend/web
|
||||||
|
- name: Run type check
|
||||||
|
run: pnpm type-check
|
||||||
|
working-directory: frontend/web
|
||||||
|
|
||||||
frontend-build:
|
frontend-build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- uses: pnpm/action-setup@v2.2.4
|
- uses: pnpm/action-setup@v4.0.0
|
||||||
with:
|
with:
|
||||||
version: 8
|
version: 9
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "18"
|
node-version: "18"
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
cache-dependency-path: "frontend/web/pnpm-lock.yaml"
|
cache-dependency-path: "frontend/web/pnpm-lock.yaml"
|
||||||
- run: pnpm install
|
- run: pnpm install
|
||||||
working-directory: frontend/web
|
working-directory: frontend/web
|
||||||
- run: pnpm type-gen
|
|
||||||
working-directory: frontend/web
|
|
||||||
- name: Run frontend build
|
- name: Run frontend build
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
working-directory: frontend/web
|
working-directory: frontend/web
|
||||||
|
2
.github/workflows/proto-linter.yml
vendored
@ -17,7 +17,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Setup buf
|
- name: Setup buf
|
||||||
|
2
.gitignore
vendored
@ -12,3 +12,5 @@ build
|
|||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
dist/
|
||||||
|
@ -20,11 +20,7 @@ issues:
|
|||||||
# https://golangci-lint.run/usage/configuration/#command-line-options
|
# https://golangci-lint.run/usage/configuration/#command-line-options
|
||||||
exclude:
|
exclude:
|
||||||
- Rollback
|
- Rollback
|
||||||
- logger.Sync
|
|
||||||
- pgInstance.Stop
|
|
||||||
- fmt.Printf
|
- fmt.Printf
|
||||||
- Enter(.*)_(.*)
|
|
||||||
- Exit(.*)_(.*)
|
|
||||||
|
|
||||||
linters-settings:
|
linters-settings:
|
||||||
goimports:
|
goimports:
|
||||||
@ -69,6 +65,10 @@ linters-settings:
|
|||||||
disabled: true
|
disabled: true
|
||||||
- name: var-naming
|
- name: var-naming
|
||||||
disabled: true
|
disabled: true
|
||||||
|
- name: unchecked-type-assertion
|
||||||
|
disabled: true
|
||||||
|
- name: max-control-nesting
|
||||||
|
disabled: true
|
||||||
- name: exported
|
- name: exported
|
||||||
arguments:
|
arguments:
|
||||||
- "disableStutteringCheck"
|
- "disableStutteringCheck"
|
||||||
|
38
.goreleaser.yaml
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
version: 1
|
||||||
|
|
||||||
|
before:
|
||||||
|
hooks:
|
||||||
|
# You may remove this if you don't use go modules.
|
||||||
|
- go mod tidy
|
||||||
|
|
||||||
|
builds:
|
||||||
|
- env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
|
main: ./bin/slash
|
||||||
|
binary: slash
|
||||||
|
goos:
|
||||||
|
- linux
|
||||||
|
- darwin
|
||||||
|
|
||||||
|
archives:
|
||||||
|
- format: tar.gz
|
||||||
|
# this name template makes the OS and Arch compatible with the results of `uname`.
|
||||||
|
name_template: >-
|
||||||
|
{{ .ProjectName }}_{{ .Tag }}_{{ .Os }}_{{ .Arch }}
|
||||||
|
|
||||||
|
changelog:
|
||||||
|
sort: asc
|
||||||
|
filters:
|
||||||
|
exclude:
|
||||||
|
- "^docs:"
|
||||||
|
- "^test:"
|
||||||
|
|
||||||
|
checksum:
|
||||||
|
disable: true
|
||||||
|
|
||||||
|
release:
|
||||||
|
draft: true
|
||||||
|
replace_existing_draft: true
|
||||||
|
make_latest: true
|
||||||
|
mode: replace
|
||||||
|
skip_upload: false
|
4
.vscode/settings.json
vendored
@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"go.lintOnSave": "workspace",
|
|
||||||
"go.lintTool": "golangci-lint"
|
|
||||||
}
|
|
@ -6,15 +6,16 @@ COPY . .
|
|||||||
|
|
||||||
WORKDIR /frontend-build/frontend/web
|
WORKDIR /frontend-build/frontend/web
|
||||||
|
|
||||||
RUN corepack enable && pnpm i --frozen-lockfile && pnpm type-gen
|
RUN corepack enable && pnpm i --frozen-lockfile
|
||||||
|
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
# Build backend exec file.
|
# Build backend exec file.
|
||||||
FROM golang:1.21-alpine AS backend
|
FROM golang:1.23-alpine AS backend
|
||||||
WORKDIR /backend-build
|
WORKDIR /backend-build
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
COPY --from=frontend /frontend-build/frontend/web/dist /backend-build/server/route/frontend/dist
|
||||||
|
|
||||||
RUN CGO_ENABLED=0 go build -o slash ./bin/slash/main.go
|
RUN CGO_ENABLED=0 go build -o slash ./bin/slash/main.go
|
||||||
|
|
||||||
@ -25,7 +26,6 @@ WORKDIR /usr/local/slash
|
|||||||
RUN apk add --no-cache tzdata
|
RUN apk add --no-cache tzdata
|
||||||
ENV TZ="UTC"
|
ENV TZ="UTC"
|
||||||
|
|
||||||
COPY --from=frontend /frontend-build/frontend/web/dist /usr/local/slash/dist
|
|
||||||
COPY --from=backend /backend-build/slash /usr/local/slash/
|
COPY --from=backend /backend-build/slash /usr/local/slash/
|
||||||
|
|
||||||
EXPOSE 5231
|
EXPOSE 5231
|
||||||
|
@ -1,14 +1,12 @@
|
|||||||
# Slash
|
# Slash
|
||||||
|
|
||||||
<img align="right" src="./docs/assets/logo.png" height="64px" alt="logo">
|
**Slash** is an open source, self-hosted platform designed to help you organize, manage, and share your most important links. Easily create customizable, human-readable shortcuts to streamline your link management. Use tags to categorize your links and share them easily with your team or publicly.
|
||||||
|
|
||||||
**Slash** is an open source, self-hosted bookmarks and link sharing platform. It allows you to organize your links with tags, and share them with custom shortened URLs. Slash also supports team sharing of link libraries for easy collaboration.
|
|
||||||
|
|
||||||
🧩 Browser extension(v1.0.0) now available! - [Chrome Web Store](https://chrome.google.com/webstore/detail/slash/ebaiehmkammnacjadffpicipfckgeobg), [Firefox Add-on](https://addons.mozilla.org/firefox/addon/your-slash/)
|
🧩 Browser extension(v1.0.0) now available! - [Chrome Web Store](https://chrome.google.com/webstore/detail/slash/ebaiehmkammnacjadffpicipfckgeobg), [Firefox Add-on](https://addons.mozilla.org/firefox/addon/your-slash/)
|
||||||
|
|
||||||
Getting started with Slash's [Shortcuts](https://github.com/yourselfhosted/slash/blob/main/docs/getting-started/shortcuts.md) and [Collections](https://github.com/yourselfhosted/slash/blob/main/docs/getting-started/collections.md).
|
Getting started with Slash's [Shortcuts](https://github.com/yourselfhosted/slash/blob/main/docs/getting-started/shortcuts.md) and [Collections](https://github.com/yourselfhosted/slash/blob/main/docs/getting-started/collections.md).
|
||||||
|
|
||||||
<a href="https://demo.slash.yourselfhosted.com">Live Demo</a> • <a href="https://discord.gg/QZqUuUAhDV">Join our Discord</a>
|
[👉 Join our Discord 💬](https://discord.gg/QZqUuUAhDV)
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<a href="https://hub.docker.com/r/yourselfhosted/slash"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/yourselfhosted/slash.svg"/></a>
|
<a href="https://hub.docker.com/r/yourselfhosted/slash"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/yourselfhosted/slash.svg"/></a>
|
||||||
|
@ -1 +0,0 @@
|
|||||||
> The v1 API has been deprecated. Please use the v2 API instead.
|
|
@ -1,12 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
type ActivityShorcutCreatePayload struct {
|
|
||||||
ShortcutID int32 `json:"shortcutId"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ActivityShorcutViewPayload struct {
|
|
||||||
ShortcutID int32 `json:"shortcutId"`
|
|
||||||
IP string `json:"ip"`
|
|
||||||
Referer string `json:"referer"`
|
|
||||||
UserAgent string `json:"userAgent"`
|
|
||||||
}
|
|
@ -1,131 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
|
||||||
"github.com/mssola/useragent"
|
|
||||||
"golang.org/x/exp/slices"
|
|
||||||
|
|
||||||
"github.com/yourselfhosted/slash/internal/util"
|
|
||||||
"github.com/yourselfhosted/slash/server/metric"
|
|
||||||
"github.com/yourselfhosted/slash/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ReferenceInfo struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Count int `json:"count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type DeviceInfo struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Count int `json:"count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type BrowserInfo struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Count int `json:"count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AnalysisData struct {
|
|
||||||
ReferenceData []ReferenceInfo `json:"referenceData"`
|
|
||||||
DeviceData []DeviceInfo `json:"deviceData"`
|
|
||||||
BrowserData []BrowserInfo `json:"browserData"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIV1Service) registerAnalyticsRoutes(g *echo.Group) {
|
|
||||||
g.GET("/shortcut/:shortcutId/analytics", func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
shortcutID, err := util.ConvertStringToInt32(c.Param("shortcutId"))
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut id is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
|
|
||||||
}
|
|
||||||
activities, err := s.Store.ListActivities(ctx, &store.FindActivity{
|
|
||||||
Type: store.ActivityShortcutView,
|
|
||||||
PayloadShortcutID: &shortcutID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get activities, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
referenceMap := make(map[string]int)
|
|
||||||
deviceMap := make(map[string]int)
|
|
||||||
browserMap := make(map[string]int)
|
|
||||||
for _, activity := range activities {
|
|
||||||
payload := &ActivityShorcutViewPayload{}
|
|
||||||
if err := json.Unmarshal([]byte(activity.Payload), payload); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to unmarshal payload, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := referenceMap[payload.Referer]; !ok {
|
|
||||||
referenceMap[payload.Referer] = 0
|
|
||||||
}
|
|
||||||
referenceMap[payload.Referer]++
|
|
||||||
|
|
||||||
ua := useragent.New(payload.UserAgent)
|
|
||||||
deviceName := ua.OSInfo().Name
|
|
||||||
browserName, _ := ua.Browser()
|
|
||||||
|
|
||||||
if _, ok := deviceMap[deviceName]; !ok {
|
|
||||||
deviceMap[deviceName] = 0
|
|
||||||
}
|
|
||||||
deviceMap[deviceName]++
|
|
||||||
|
|
||||||
if _, ok := browserMap[browserName]; !ok {
|
|
||||||
browserMap[browserName] = 0
|
|
||||||
}
|
|
||||||
browserMap[browserName]++
|
|
||||||
}
|
|
||||||
|
|
||||||
metric.Enqueue("shortcut analytics")
|
|
||||||
return c.JSON(http.StatusOK, &AnalysisData{
|
|
||||||
ReferenceData: mapToReferenceInfoSlice(referenceMap),
|
|
||||||
DeviceData: mapToDeviceInfoSlice(deviceMap),
|
|
||||||
BrowserData: mapToBrowserInfoSlice(browserMap),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapToReferenceInfoSlice(m map[string]int) []ReferenceInfo {
|
|
||||||
referenceInfoSlice := make([]ReferenceInfo, 0)
|
|
||||||
for key, value := range m {
|
|
||||||
referenceInfoSlice = append(referenceInfoSlice, ReferenceInfo{
|
|
||||||
Name: key,
|
|
||||||
Count: value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
slices.SortFunc(referenceInfoSlice, func(i, j ReferenceInfo) int {
|
|
||||||
return i.Count - j.Count
|
|
||||||
})
|
|
||||||
return referenceInfoSlice
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapToDeviceInfoSlice(m map[string]int) []DeviceInfo {
|
|
||||||
deviceInfoSlice := make([]DeviceInfo, 0)
|
|
||||||
for key, value := range m {
|
|
||||||
deviceInfoSlice = append(deviceInfoSlice, DeviceInfo{
|
|
||||||
Name: key,
|
|
||||||
Count: value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
slices.SortFunc(deviceInfoSlice, func(i, j DeviceInfo) int {
|
|
||||||
return i.Count - j.Count
|
|
||||||
})
|
|
||||||
return deviceInfoSlice
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapToBrowserInfoSlice(m map[string]int) []BrowserInfo {
|
|
||||||
browserInfoSlice := make([]BrowserInfo, 0)
|
|
||||||
for key, value := range m {
|
|
||||||
browserInfoSlice = append(browserInfoSlice, BrowserInfo{
|
|
||||||
Name: key,
|
|
||||||
Count: value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
slices.SortFunc(browserInfoSlice, func(i, j BrowserInfo) int {
|
|
||||||
return i.Count - j.Count
|
|
||||||
})
|
|
||||||
return browserInfoSlice
|
|
||||||
}
|
|
211
api/v1/auth.go
@ -1,211 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
|
|
||||||
"github.com/yourselfhosted/slash/api/auth"
|
|
||||||
storepb "github.com/yourselfhosted/slash/proto/gen/store"
|
|
||||||
"github.com/yourselfhosted/slash/server/metric"
|
|
||||||
"github.com/yourselfhosted/slash/server/service/license"
|
|
||||||
"github.com/yourselfhosted/slash/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
type SignInRequest struct {
|
|
||||||
Email string `json:"email"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SignUpRequest struct {
|
|
||||||
Nickname string `json:"nickname"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIV1Service) registerAuthRoutes(g *echo.Group, secret string) {
|
|
||||||
g.POST("/auth/signin", func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
signin := &SignInRequest{}
|
|
||||||
if err := json.NewDecoder(c.Request().Body).Decode(signin); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("malformatted signin request, err: %s", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
|
||||||
Email: &signin.Email,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find user by email %s", signin.Email)).SetInternal(err)
|
|
||||||
}
|
|
||||||
if user == nil {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("user not found with email %s", signin.Email))
|
|
||||||
} else if user.RowStatus == store.Archived {
|
|
||||||
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("user has been archived with email %s", signin.Email))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compare the stored hashed password, with the hashed version of the password that was received.
|
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(signin.Password)); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "unmatched email and password")
|
|
||||||
}
|
|
||||||
|
|
||||||
accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, time.Now().Add(auth.AccessTokenDuration), []byte(secret))
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate tokens, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
if err := s.UpsertAccessTokenToStore(ctx, user, accessToken); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert access token, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cookieExp := time.Now().Add(auth.CookieExpDuration)
|
|
||||||
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
|
|
||||||
metric.Enqueue("user sign in")
|
|
||||||
return c.JSON(http.StatusOK, convertUserFromStore(user))
|
|
||||||
})
|
|
||||||
|
|
||||||
g.POST("/auth/signup", func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
enableSignUpSetting, err := s.Store.GetWorkspaceSetting(ctx, &store.FindWorkspaceSetting{
|
|
||||||
Key: storepb.WorkspaceSettingKey_WORKSAPCE_SETTING_ENABLE_SIGNUP,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get workspace setting, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
if enableSignUpSetting != nil && !enableSignUpSetting.GetEnableSignup() {
|
|
||||||
return echo.NewHTTPError(http.StatusForbidden, "sign up has been disabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !s.LicenseService.IsFeatureEnabled(license.FeatureTypeUnlimitedAccounts) {
|
|
||||||
userList, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to list users").SetInternal(err)
|
|
||||||
}
|
|
||||||
if len(userList) >= 5 {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, "Maximum number of users reached")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
signup := &SignUpRequest{}
|
|
||||||
if err := json.NewDecoder(c.Request().Body).Decode(signup); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("malformatted signup request, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(signup.Password), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to generate password hash").SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
create := &store.User{
|
|
||||||
Email: signup.Email,
|
|
||||||
Nickname: signup.Nickname,
|
|
||||||
PasswordHash: string(passwordHash),
|
|
||||||
}
|
|
||||||
existingUsers, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find existing users, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
// The first user to sign up is an admin by default.
|
|
||||||
if len(existingUsers) == 0 {
|
|
||||||
create.Role = store.RoleAdmin
|
|
||||||
} else {
|
|
||||||
create.Role = store.RoleUser
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := s.Store.CreateUser(ctx, create)
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create user, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, time.Now().Add(auth.AccessTokenDuration), []byte(secret))
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate tokens, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
if err := s.UpsertAccessTokenToStore(ctx, user, accessToken); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert access token, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cookieExp := time.Now().Add(auth.CookieExpDuration)
|
|
||||||
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
|
|
||||||
metric.Enqueue("user sign up")
|
|
||||||
return c.JSON(http.StatusOK, convertUserFromStore(user))
|
|
||||||
})
|
|
||||||
|
|
||||||
g.POST("/auth/logout", func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
RemoveTokensAndCookies(c)
|
|
||||||
accessToken := findAccessToken(c)
|
|
||||||
userID, _ := getUserIDFromAccessToken(accessToken, secret)
|
|
||||||
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, userID)
|
|
||||||
// Auto remove the current access token from the user access tokens.
|
|
||||||
if err == nil && len(userAccessTokens) != 0 {
|
|
||||||
accessTokens := []*storepb.AccessTokensUserSetting_AccessToken{}
|
|
||||||
for _, userAccessToken := range userAccessTokens {
|
|
||||||
if accessToken != userAccessToken.AccessToken {
|
|
||||||
accessTokens = append(accessTokens, userAccessToken)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
|
|
||||||
UserId: userID,
|
|
||||||
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
|
|
||||||
Value: &storepb.UserSetting_AccessTokens{
|
|
||||||
AccessTokens: &storepb.AccessTokensUserSetting{
|
|
||||||
AccessTokens: accessTokens,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert user setting, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c.Response().WriteHeader(http.StatusOK)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIV1Service) UpsertAccessTokenToStore(ctx context.Context, user *store.User, accessToken string) error {
|
|
||||||
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, user.ID)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "failed to get user access tokens")
|
|
||||||
}
|
|
||||||
userAccessToken := storepb.AccessTokensUserSetting_AccessToken{
|
|
||||||
AccessToken: accessToken,
|
|
||||||
Description: "Account sign in",
|
|
||||||
}
|
|
||||||
userAccessTokens = append(userAccessTokens, &userAccessToken)
|
|
||||||
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
|
|
||||||
UserId: user.ID,
|
|
||||||
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
|
|
||||||
Value: &storepb.UserSetting_AccessTokens{
|
|
||||||
AccessTokens: &storepb.AccessTokensUserSetting{
|
|
||||||
AccessTokens: userAccessTokens,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert user setting, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveTokensAndCookies removes the jwt token from the cookies.
|
|
||||||
func RemoveTokensAndCookies(c echo.Context) {
|
|
||||||
cookieExp := time.Now().Add(-1 * time.Hour)
|
|
||||||
setTokenCookie(c, auth.AccessTokenCookieName, "", cookieExp)
|
|
||||||
}
|
|
||||||
|
|
||||||
// setTokenCookie sets the token to the cookie.
|
|
||||||
func setTokenCookie(c echo.Context, name, token string, expiration time.Time) {
|
|
||||||
cookie := new(http.Cookie)
|
|
||||||
cookie.Name = name
|
|
||||||
cookie.Value = token
|
|
||||||
cookie.Expires = expiration
|
|
||||||
cookie.Path = "/"
|
|
||||||
// Http-only helps mitigate the risk of client side script accessing the protected cookie.
|
|
||||||
cookie.HttpOnly = true
|
|
||||||
cookie.SameSite = http.SameSiteStrictMode
|
|
||||||
c.SetCookie(cookie)
|
|
||||||
}
|
|
@ -1,15 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
// RowStatus is the status for a row.
|
|
||||||
type RowStatus string
|
|
||||||
|
|
||||||
const (
|
|
||||||
// Normal is the status for a normal row.
|
|
||||||
Normal RowStatus = "NORMAL"
|
|
||||||
// Archived is the status for an archived row.
|
|
||||||
Archived RowStatus = "ARCHIVED"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s RowStatus) String() string {
|
|
||||||
return string(s)
|
|
||||||
}
|
|
133
api/v1/jwt.go
@ -1,133 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v4"
|
|
||||||
"github.com/labstack/echo/v4"
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
|
|
||||||
"github.com/yourselfhosted/slash/api/auth"
|
|
||||||
"github.com/yourselfhosted/slash/internal/util"
|
|
||||||
storepb "github.com/yourselfhosted/slash/proto/gen/store"
|
|
||||||
"github.com/yourselfhosted/slash/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// The key name used to store user id in the context
|
|
||||||
// user id is extracted from the jwt token subject field.
|
|
||||||
userIDContextKey = "user-id"
|
|
||||||
)
|
|
||||||
|
|
||||||
func extractTokenFromHeader(c echo.Context) (string, error) {
|
|
||||||
authHeader := c.Request().Header.Get("Authorization")
|
|
||||||
if authHeader == "" {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
authHeaderParts := strings.Fields(authHeader)
|
|
||||||
if len(authHeaderParts) != 2 || strings.ToLower(authHeaderParts[0]) != "bearer" {
|
|
||||||
return "", errors.New("Authorization header format must be Bearer {token}")
|
|
||||||
}
|
|
||||||
|
|
||||||
return authHeaderParts[1], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func findAccessToken(c echo.Context) string {
|
|
||||||
// Check the HTTP request header first.
|
|
||||||
accessToken, _ := extractTokenFromHeader(c)
|
|
||||||
if accessToken == "" {
|
|
||||||
// Check the cookie.
|
|
||||||
cookie, _ := c.Cookie(auth.AccessTokenCookieName)
|
|
||||||
if cookie != nil {
|
|
||||||
accessToken = cookie.Value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return accessToken
|
|
||||||
}
|
|
||||||
|
|
||||||
// JWTMiddleware validates the access token.
|
|
||||||
func JWTMiddleware(s *APIV1Service, next echo.HandlerFunc, secret string) echo.HandlerFunc {
|
|
||||||
return func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
path := c.Request().URL.Path
|
|
||||||
method := c.Request().Method
|
|
||||||
|
|
||||||
// Pass auth and profile endpoints.
|
|
||||||
if util.HasPrefixes(path, "/api/v1/auth", "/api/v1/workspace/profile") {
|
|
||||||
return next(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
accessToken := findAccessToken(c)
|
|
||||||
if accessToken == "" {
|
|
||||||
// When the request is not authenticated, we allow the user to access the shortcut endpoints for those public shortcuts.
|
|
||||||
if util.HasPrefixes(path, "/s/", "/api/v1/user/") && method == http.MethodGet {
|
|
||||||
return next(c)
|
|
||||||
}
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing access token")
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, err := getUserIDFromAccessToken(accessToken, secret)
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid or expired access token")
|
|
||||||
}
|
|
||||||
|
|
||||||
accessTokens, err := s.Store.GetUserAccessTokens(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get user access tokens.").WithInternal(err)
|
|
||||||
}
|
|
||||||
if !validateAccessToken(accessToken, accessTokens) {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid access token.")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Even if there is no error, we still need to make sure the user still exists.
|
|
||||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
|
||||||
ID: &userID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Server error to find user ID: %d", userID)).SetInternal(err)
|
|
||||||
}
|
|
||||||
if user == nil {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Failed to find user ID: %d", userID))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stores userID into context.
|
|
||||||
c.Set(userIDContextKey, userID)
|
|
||||||
return next(c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getUserIDFromAccessToken(accessToken, secret string) (int32, error) {
|
|
||||||
claims := &auth.ClaimsMessage{}
|
|
||||||
_, err := jwt.ParseWithClaims(accessToken, claims, func(t *jwt.Token) (any, error) {
|
|
||||||
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
|
|
||||||
return nil, errors.Errorf("unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
|
|
||||||
}
|
|
||||||
if kid, ok := t.Header["kid"].(string); ok {
|
|
||||||
if kid == "v1" {
|
|
||||||
return []byte(secret), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, errors.Errorf("unexpected access token kid=%v", t.Header["kid"])
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return 0, errors.Wrap(err, "Invalid or expired access token")
|
|
||||||
}
|
|
||||||
// We either have a valid access token or we will attempt to generate new access token.
|
|
||||||
userID, err := util.ConvertStringToInt32(claims.Subject)
|
|
||||||
if err != nil {
|
|
||||||
return 0, errors.Wrap(err, "Malformed ID in the token")
|
|
||||||
}
|
|
||||||
return userID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateAccessToken(accessTokenString string, userAccessTokens []*storepb.AccessTokensUserSetting_AccessToken) bool {
|
|
||||||
for _, userAccessToken := range userAccessTokens {
|
|
||||||
if accessTokenString == userAccessToken.AccessToken {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
@ -1,386 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
|
|
||||||
"github.com/yourselfhosted/slash/internal/util"
|
|
||||||
storepb "github.com/yourselfhosted/slash/proto/gen/store"
|
|
||||||
"github.com/yourselfhosted/slash/server/metric"
|
|
||||||
"github.com/yourselfhosted/slash/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Visibility is the type of a shortcut visibility.
|
|
||||||
type Visibility string
|
|
||||||
|
|
||||||
const (
|
|
||||||
// VisibilityPublic is the PUBLIC visibility.
|
|
||||||
VisibilityPublic Visibility = "PUBLIC"
|
|
||||||
// VisibilityWorkspace is the WORKSPACE visibility.
|
|
||||||
VisibilityWorkspace Visibility = "WORKSPACE"
|
|
||||||
// VisibilityPrivate is the PRIVATE visibility.
|
|
||||||
VisibilityPrivate Visibility = "PRIVATE"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (v Visibility) String() string {
|
|
||||||
return string(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
type OpenGraphMetadata struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Image string `json:"image"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Shortcut struct {
|
|
||||||
ID int32 `json:"id"`
|
|
||||||
|
|
||||||
// Standard fields
|
|
||||||
CreatorID int32 `json:"creatorId"`
|
|
||||||
Creator *User `json:"creator"`
|
|
||||||
CreatedTs int64 `json:"createdTs"`
|
|
||||||
UpdatedTs int64 `json:"updatedTs"`
|
|
||||||
RowStatus RowStatus `json:"rowStatus"`
|
|
||||||
|
|
||||||
// Domain specific fields
|
|
||||||
Name string `json:"name"`
|
|
||||||
Link string `json:"link"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Visibility Visibility `json:"visibility"`
|
|
||||||
Tags []string `json:"tags"`
|
|
||||||
View int `json:"view"`
|
|
||||||
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CreateShortcutRequest struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Link string `json:"link"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Visibility Visibility `json:"visibility"`
|
|
||||||
Tags []string `json:"tags"`
|
|
||||||
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PatchShortcutRequest struct {
|
|
||||||
RowStatus *RowStatus `json:"rowStatus"`
|
|
||||||
Name *string `json:"name"`
|
|
||||||
Link *string `json:"link"`
|
|
||||||
Title *string `json:"title"`
|
|
||||||
Description *string `json:"description"`
|
|
||||||
Visibility *Visibility `json:"visibility"`
|
|
||||||
Tags []string `json:"tags"`
|
|
||||||
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|
||||||
g.POST("/shortcut", func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
userID, ok := c.Get(userIDContextKey).(int32)
|
|
||||||
if !ok {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
|
||||||
}
|
|
||||||
create := &CreateShortcutRequest{}
|
|
||||||
if err := json.NewDecoder(c.Request().Body).Decode(create); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("malformatted post shortcut request, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
shortcut := &storepb.Shortcut{
|
|
||||||
CreatorId: userID,
|
|
||||||
Name: create.Name,
|
|
||||||
Link: create.Link,
|
|
||||||
Title: create.Title,
|
|
||||||
Description: create.Description,
|
|
||||||
Visibility: convertVisibilityToStorepb(create.Visibility),
|
|
||||||
Tags: create.Tags,
|
|
||||||
OgMetadata: &storepb.OpenGraphMetadata{},
|
|
||||||
}
|
|
||||||
if create.Name == "" {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, "name is required")
|
|
||||||
}
|
|
||||||
if create.OpenGraphMetadata != nil {
|
|
||||||
shortcut.OgMetadata = &storepb.OpenGraphMetadata{
|
|
||||||
Title: create.OpenGraphMetadata.Title,
|
|
||||||
Description: create.OpenGraphMetadata.Description,
|
|
||||||
Image: create.OpenGraphMetadata.Image,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
shortcut, err := s.Store.CreateShortcut(ctx, shortcut)
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create shortcut, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.createShortcutCreateActivity(ctx, shortcut); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create shortcut activity, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
metric.Enqueue("shortcut create")
|
|
||||||
return c.JSON(http.StatusOK, shortcutMessage)
|
|
||||||
})
|
|
||||||
|
|
||||||
g.PATCH("/shortcut/:shortcutId", func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
shortcutID, err := util.ConvertStringToInt32(c.Param("shortcutId"))
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut ID is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
|
|
||||||
}
|
|
||||||
userID, ok := c.Get(userIDContextKey).(int32)
|
|
||||||
if !ok {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
|
||||||
}
|
|
||||||
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
|
||||||
ID: &userID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find user, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
|
||||||
ID: &shortcutID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find shortcut, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
if shortcut == nil {
|
|
||||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with id: %d", shortcutID))
|
|
||||||
}
|
|
||||||
if shortcut.CreatorId != userID && currentUser.Role != store.RoleAdmin {
|
|
||||||
return echo.NewHTTPError(http.StatusForbidden, "unauthorized to update shortcut")
|
|
||||||
}
|
|
||||||
|
|
||||||
patch := &PatchShortcutRequest{}
|
|
||||||
if err := json.NewDecoder(c.Request().Body).Decode(patch); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to decode patch shortcut request, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
shortcutUpdate := &store.UpdateShortcut{
|
|
||||||
ID: shortcutID,
|
|
||||||
Name: patch.Name,
|
|
||||||
Link: patch.Link,
|
|
||||||
Title: patch.Title,
|
|
||||||
Description: patch.Description,
|
|
||||||
}
|
|
||||||
if patch.RowStatus != nil {
|
|
||||||
shortcutUpdate.RowStatus = (*store.RowStatus)(patch.RowStatus)
|
|
||||||
}
|
|
||||||
if patch.Visibility != nil {
|
|
||||||
shortcutUpdate.Visibility = (*store.Visibility)(patch.Visibility)
|
|
||||||
}
|
|
||||||
if patch.Tags != nil {
|
|
||||||
tag := strings.Join(patch.Tags, " ")
|
|
||||||
shortcutUpdate.Tag = &tag
|
|
||||||
}
|
|
||||||
if patch.OpenGraphMetadata != nil {
|
|
||||||
shortcutUpdate.OpenGraphMetadata = &storepb.OpenGraphMetadata{
|
|
||||||
Title: patch.OpenGraphMetadata.Title,
|
|
||||||
Description: patch.OpenGraphMetadata.Description,
|
|
||||||
Image: patch.OpenGraphMetadata.Image,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
shortcut, err = s.Store.UpdateShortcut(ctx, shortcutUpdate)
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to patch shortcut, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
return c.JSON(http.StatusOK, shortcutMessage)
|
|
||||||
})
|
|
||||||
|
|
||||||
g.GET("/shortcut", func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
userID, ok := c.Get(userIDContextKey).(int32)
|
|
||||||
if !ok {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
|
||||||
}
|
|
||||||
|
|
||||||
find := &store.FindShortcut{}
|
|
||||||
if tag := c.QueryParam("tag"); tag != "" {
|
|
||||||
find.Tag = &tag
|
|
||||||
}
|
|
||||||
|
|
||||||
list := []*storepb.Shortcut{}
|
|
||||||
find.VisibilityList = []store.Visibility{store.VisibilityWorkspace, store.VisibilityPublic}
|
|
||||||
visibleShortcutList, err := s.Store.ListShortcuts(ctx, find)
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to fetch shortcut list, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
list = append(list, visibleShortcutList...)
|
|
||||||
|
|
||||||
find.VisibilityList = []store.Visibility{store.VisibilityPrivate}
|
|
||||||
find.CreatorID = &userID
|
|
||||||
privateShortcutList, err := s.Store.ListShortcuts(ctx, find)
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to fetch private shortcut list, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
list = append(list, privateShortcutList...)
|
|
||||||
|
|
||||||
shortcutMessageList := []*Shortcut{}
|
|
||||||
for _, shortcut := range list {
|
|
||||||
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
shortcutMessageList = append(shortcutMessageList, shortcutMessage)
|
|
||||||
}
|
|
||||||
return c.JSON(http.StatusOK, shortcutMessageList)
|
|
||||||
})
|
|
||||||
|
|
||||||
g.GET("/shortcut/:id", func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
shortcutID, err := util.ConvertStringToInt32(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut id is not a number: %s", c.Param("id"))).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
|
||||||
ID: &shortcutID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to fetch shortcut by id, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
if shortcut == nil {
|
|
||||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with id: %d", shortcutID))
|
|
||||||
}
|
|
||||||
|
|
||||||
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
return c.JSON(http.StatusOK, shortcutMessage)
|
|
||||||
})
|
|
||||||
|
|
||||||
g.DELETE("/shortcut/:id", func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
shortcutID, err := util.ConvertStringToInt32(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut id is not a number: %s", c.Param("id"))).SetInternal(err)
|
|
||||||
}
|
|
||||||
userID, ok := c.Get(userIDContextKey).(int32)
|
|
||||||
if !ok {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
|
||||||
}
|
|
||||||
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
|
||||||
ID: &userID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find user, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
|
||||||
ID: &shortcutID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to fetch shortcut by id, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
if shortcut == nil {
|
|
||||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with id: %d", shortcutID))
|
|
||||||
}
|
|
||||||
if shortcut.CreatorId != userID && currentUser.Role != store.RoleAdmin {
|
|
||||||
return echo.NewHTTPError(http.StatusForbidden, "Unauthorized to delete shortcut")
|
|
||||||
}
|
|
||||||
|
|
||||||
err = s.Store.DeleteShortcut(ctx, &store.DeleteShortcut{ID: shortcutID})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to delete shortcut, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
return c.JSON(http.StatusOK, true)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIV1Service) composeShortcut(ctx context.Context, shortcut *Shortcut) (*Shortcut, error) {
|
|
||||||
if shortcut == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
|
||||||
ID: &shortcut.CreatorID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "Failed to get creator")
|
|
||||||
}
|
|
||||||
if user == nil {
|
|
||||||
return nil, errors.New("Creator not found")
|
|
||||||
}
|
|
||||||
shortcut.Creator = convertUserFromStore(user)
|
|
||||||
|
|
||||||
activityList, err := s.Store.ListActivities(ctx, &store.FindActivity{
|
|
||||||
Type: store.ActivityShortcutView,
|
|
||||||
Level: store.ActivityInfo,
|
|
||||||
PayloadShortcutID: &shortcut.ID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "Failed to list activities")
|
|
||||||
}
|
|
||||||
shortcut.View = len(activityList)
|
|
||||||
|
|
||||||
return shortcut, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func convertShortcutFromStorepb(shortcut *storepb.Shortcut) *Shortcut {
|
|
||||||
return &Shortcut{
|
|
||||||
ID: shortcut.Id,
|
|
||||||
CreatedTs: shortcut.CreatedTs,
|
|
||||||
UpdatedTs: shortcut.UpdatedTs,
|
|
||||||
CreatorID: shortcut.CreatorId,
|
|
||||||
RowStatus: RowStatus(shortcut.RowStatus.String()),
|
|
||||||
Name: shortcut.Name,
|
|
||||||
Link: shortcut.Link,
|
|
||||||
Title: shortcut.Title,
|
|
||||||
Description: shortcut.Description,
|
|
||||||
Visibility: Visibility(shortcut.Visibility.String()),
|
|
||||||
Tags: shortcut.Tags,
|
|
||||||
OpenGraphMetadata: &OpenGraphMetadata{
|
|
||||||
Title: shortcut.OgMetadata.Title,
|
|
||||||
Description: shortcut.OgMetadata.Description,
|
|
||||||
Image: shortcut.OgMetadata.Image,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func convertVisibilityToStorepb(visibility Visibility) storepb.Visibility {
|
|
||||||
switch visibility {
|
|
||||||
case VisibilityPublic:
|
|
||||||
return storepb.Visibility_PUBLIC
|
|
||||||
case VisibilityWorkspace:
|
|
||||||
return storepb.Visibility_WORKSPACE
|
|
||||||
case VisibilityPrivate:
|
|
||||||
return storepb.Visibility_PRIVATE
|
|
||||||
default:
|
|
||||||
return storepb.Visibility_PUBLIC
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIV1Service) createShortcutCreateActivity(ctx context.Context, shortcut *storepb.Shortcut) error {
|
|
||||||
payload := &ActivityShorcutCreatePayload{
|
|
||||||
ShortcutID: shortcut.Id,
|
|
||||||
}
|
|
||||||
payloadStr, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "Failed to marshal activity payload")
|
|
||||||
}
|
|
||||||
activity := &store.Activity{
|
|
||||||
CreatorID: shortcut.CreatorId,
|
|
||||||
Type: store.ActivityShortcutCreate,
|
|
||||||
Level: store.ActivityInfo,
|
|
||||||
Payload: string(payloadStr),
|
|
||||||
}
|
|
||||||
_, err = s.Store.CreateActivity(ctx, activity)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "Failed to create activity")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
340
api/v1/user.go
@ -1,340 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/mail"
|
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
|
|
||||||
"github.com/yourselfhosted/slash/internal/util"
|
|
||||||
"github.com/yourselfhosted/slash/server/metric"
|
|
||||||
"github.com/yourselfhosted/slash/server/service/license"
|
|
||||||
"github.com/yourselfhosted/slash/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// BotID is the id of bot.
|
|
||||||
BotID = 0
|
|
||||||
)
|
|
||||||
|
|
||||||
// Role is the type of a role.
|
|
||||||
type Role string
|
|
||||||
|
|
||||||
const (
|
|
||||||
// RoleAdmin is the ADMIN role.
|
|
||||||
RoleAdmin Role = "ADMIN"
|
|
||||||
// RoleUser is the USER role.
|
|
||||||
RoleUser Role = "USER"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (r Role) String() string {
|
|
||||||
switch r {
|
|
||||||
case RoleAdmin:
|
|
||||||
return "ADMIN"
|
|
||||||
case RoleUser:
|
|
||||||
return "USER"
|
|
||||||
}
|
|
||||||
return "USER"
|
|
||||||
}
|
|
||||||
|
|
||||||
type User struct {
|
|
||||||
ID int32 `json:"id"`
|
|
||||||
|
|
||||||
// Standard fields
|
|
||||||
CreatedTs int64 `json:"createdTs"`
|
|
||||||
UpdatedTs int64 `json:"updatedTs"`
|
|
||||||
RowStatus RowStatus `json:"rowStatus"`
|
|
||||||
|
|
||||||
// Domain specific fields
|
|
||||||
Email string `json:"email"`
|
|
||||||
Nickname string `json:"nickname"`
|
|
||||||
Role Role `json:"role"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CreateUserRequest struct {
|
|
||||||
Email string `json:"email"`
|
|
||||||
Nickname string `json:"nickname"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
Role Role `json:"role"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (create CreateUserRequest) Validate() error {
|
|
||||||
if create.Email != "" && !validateEmail(create.Email) {
|
|
||||||
return errors.New("invalid email format")
|
|
||||||
}
|
|
||||||
if create.Nickname != "" && len(create.Nickname) < 3 {
|
|
||||||
return errors.New("nickname is too short, minimum length is 3")
|
|
||||||
}
|
|
||||||
if len(create.Password) < 3 {
|
|
||||||
return errors.New("password is too short, minimum length is 3")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type PatchUserRequest struct {
|
|
||||||
RowStatus *RowStatus `json:"rowStatus"`
|
|
||||||
Email *string `json:"email"`
|
|
||||||
Nickname *string `json:"nickname"`
|
|
||||||
Password *string `json:"password"`
|
|
||||||
Role *Role `json:"role"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
|
||||||
g.POST("/user", func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
userID, ok := c.Get(userIDContextKey).(int32)
|
|
||||||
if !ok {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
|
|
||||||
}
|
|
||||||
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
|
||||||
ID: &userID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by id").SetInternal(err)
|
|
||||||
}
|
|
||||||
if currentUser == nil {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
|
|
||||||
}
|
|
||||||
if currentUser.Role != store.RoleAdmin {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized to create user")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !s.LicenseService.IsFeatureEnabled(license.FeatureTypeUnlimitedAccounts) {
|
|
||||||
userList, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to list users").SetInternal(err)
|
|
||||||
}
|
|
||||||
if len(userList) >= 5 {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, "Maximum number of users reached")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
userCreate := &CreateUserRequest{}
|
|
||||||
if err := json.NewDecoder(c.Request().Body).Decode(userCreate); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user request").SetInternal(err)
|
|
||||||
}
|
|
||||||
if err := userCreate.Validate(); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user create format").SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(userCreate.Password), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := s.Store.CreateUser(ctx, &store.User{
|
|
||||||
Role: store.Role(userCreate.Role),
|
|
||||||
Email: userCreate.Email,
|
|
||||||
Nickname: userCreate.Nickname,
|
|
||||||
PasswordHash: string(passwordHash),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
userMessage := convertUserFromStore(user)
|
|
||||||
metric.Enqueue("user create")
|
|
||||||
return c.JSON(http.StatusOK, userMessage)
|
|
||||||
})
|
|
||||||
|
|
||||||
g.GET("/user", func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
list, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to list users, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
userList := []*User{}
|
|
||||||
for _, user := range list {
|
|
||||||
userList = append(userList, convertUserFromStore(user))
|
|
||||||
}
|
|
||||||
return c.JSON(http.StatusOK, userList)
|
|
||||||
})
|
|
||||||
|
|
||||||
// GET /api/user/me is used to check if the user is logged in.
|
|
||||||
g.GET("/user/me", func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
userID, ok := c.Get(userIDContextKey).(int32)
|
|
||||||
if !ok {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing auth session")
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
|
||||||
ID: &userID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, convertUserFromStore(user))
|
|
||||||
})
|
|
||||||
|
|
||||||
g.GET("/user/:id", func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
userID, err := util.ConvertStringToInt32(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
|
||||||
ID: &userID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
userMessage := convertUserFromStore(user)
|
|
||||||
userID, ok := c.Get(userIDContextKey).(int32)
|
|
||||||
if !ok {
|
|
||||||
userMessage.Email = ""
|
|
||||||
}
|
|
||||||
return c.JSON(http.StatusOK, userMessage)
|
|
||||||
})
|
|
||||||
|
|
||||||
g.PATCH("/user/:id", func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
userID, err := util.ConvertStringToInt32(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err)
|
|
||||||
}
|
|
||||||
currentUserID, ok := c.Get(userIDContextKey).(int32)
|
|
||||||
if !ok {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
|
||||||
}
|
|
||||||
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
|
||||||
ID: ¤tUserID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to find current user").SetInternal(err)
|
|
||||||
}
|
|
||||||
if currentUser == nil {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
|
||||||
}
|
|
||||||
if currentUser.ID != userID && currentUser.Role != store.RoleAdmin {
|
|
||||||
return echo.NewHTTPError(http.StatusForbidden, "access forbidden for current session user").SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
userPatch := &PatchUserRequest{}
|
|
||||||
if err := json.NewDecoder(c.Request().Body).Decode(userPatch); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to decode request body, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
updateUser := &store.UpdateUser{
|
|
||||||
ID: userID,
|
|
||||||
}
|
|
||||||
if userPatch.Email != nil {
|
|
||||||
if !validateEmail(*userPatch.Email) {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid email format: %s", *userPatch.Email))
|
|
||||||
}
|
|
||||||
updateUser.Email = userPatch.Email
|
|
||||||
}
|
|
||||||
if userPatch.Nickname != nil {
|
|
||||||
updateUser.Nickname = userPatch.Nickname
|
|
||||||
}
|
|
||||||
if userPatch.Password != nil && *userPatch.Password != "" {
|
|
||||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(*userPatch.Password), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to hash password, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
passwordHashStr := string(passwordHash)
|
|
||||||
updateUser.PasswordHash = &passwordHashStr
|
|
||||||
}
|
|
||||||
if userPatch.RowStatus != nil {
|
|
||||||
rowStatus := store.RowStatus(*userPatch.RowStatus)
|
|
||||||
updateUser.RowStatus = &rowStatus
|
|
||||||
}
|
|
||||||
if userPatch.Role != nil {
|
|
||||||
adminRole := store.RoleAdmin
|
|
||||||
adminUsers, err := s.Store.ListUsers(ctx, &store.FindUser{
|
|
||||||
Role: &adminRole,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to list admin users, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
if len(adminUsers) == 1 && adminUsers[0].ID == userID && *userPatch.Role != RoleAdmin {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, "cannot remove admin role from the last admin user")
|
|
||||||
}
|
|
||||||
role := store.Role(*userPatch.Role)
|
|
||||||
updateUser.Role = &role
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := s.Store.UpdateUser(ctx, updateUser)
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to update user, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, convertUserFromStore(user))
|
|
||||||
})
|
|
||||||
|
|
||||||
g.DELETE("/user/:id", func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
currentUserID, ok := c.Get(userIDContextKey).(int32)
|
|
||||||
if !ok {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
|
||||||
}
|
|
||||||
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
|
||||||
ID: ¤tUserID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find current session user, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
if currentUser == nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("current session user not found with ID: %d", currentUserID)).SetInternal(err)
|
|
||||||
}
|
|
||||||
if currentUser.Role != store.RoleAdmin {
|
|
||||||
return echo.NewHTTPError(http.StatusForbidden, "access forbidden for current session user").SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, err := util.ConvertStringToInt32(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err)
|
|
||||||
}
|
|
||||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
|
||||||
ID: &userID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
if user == nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user not found with ID: %d", userID)).SetInternal(err)
|
|
||||||
}
|
|
||||||
if user.Role == store.RoleAdmin {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("cannot delete admin user with ID: %d", userID)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.Store.DeleteUser(ctx, &store.DeleteUser{
|
|
||||||
ID: userID,
|
|
||||||
}); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to delete user, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, true)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateEmail validates the email.
|
|
||||||
func validateEmail(email string) bool {
|
|
||||||
if _, err := mail.ParseAddress(email); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// convertUserFromStore converts a store user to a user.
|
|
||||||
func convertUserFromStore(user *store.User) *User {
|
|
||||||
return &User{
|
|
||||||
ID: user.ID,
|
|
||||||
CreatedTs: user.CreatedTs,
|
|
||||||
UpdatedTs: user.UpdatedTs,
|
|
||||||
RowStatus: RowStatus(user.RowStatus),
|
|
||||||
Email: user.Email,
|
|
||||||
Nickname: user.Nickname,
|
|
||||||
Role: Role(user.Role),
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,67 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
)
|
|
||||||
|
|
||||||
type UserSettingKey string
|
|
||||||
|
|
||||||
const (
|
|
||||||
// UserSettingLocaleKey is the key type for user locale.
|
|
||||||
UserSettingLocaleKey UserSettingKey = "locale"
|
|
||||||
)
|
|
||||||
|
|
||||||
// String returns the string format of UserSettingKey type.
|
|
||||||
func (k UserSettingKey) String() string {
|
|
||||||
return string(k)
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
UserSettingLocaleValue = []string{"en", "zh"}
|
|
||||||
)
|
|
||||||
|
|
||||||
type UserSetting struct {
|
|
||||||
UserID int
|
|
||||||
Key UserSettingKey `json:"key"`
|
|
||||||
// Value is a JSON string with basic value.
|
|
||||||
Value string `json:"value"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserSettingUpsert struct {
|
|
||||||
UserID int
|
|
||||||
Key UserSettingKey `json:"key"`
|
|
||||||
Value string `json:"value"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (upsert UserSettingUpsert) Validate() error {
|
|
||||||
if upsert.Key == UserSettingLocaleKey {
|
|
||||||
localeValue := "en"
|
|
||||||
err := json.Unmarshal([]byte(upsert.Value), &localeValue)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New("failed to unmarshal user setting locale value")
|
|
||||||
}
|
|
||||||
|
|
||||||
invalid := true
|
|
||||||
for _, value := range UserSettingLocaleValue {
|
|
||||||
if localeValue == value {
|
|
||||||
invalid = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if invalid {
|
|
||||||
return errors.New("invalid user setting locale value")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return errors.New("invalid user setting key")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserSettingFind struct {
|
|
||||||
UserID int
|
|
||||||
|
|
||||||
Key *UserSettingKey `json:"key"`
|
|
||||||
}
|
|
35
api/v1/v1.go
@ -1,35 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/labstack/echo/v4"
|
|
||||||
|
|
||||||
"github.com/yourselfhosted/slash/server/profile"
|
|
||||||
"github.com/yourselfhosted/slash/server/service/license"
|
|
||||||
"github.com/yourselfhosted/slash/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
type APIV1Service struct {
|
|
||||||
Profile *profile.Profile
|
|
||||||
Store *store.Store
|
|
||||||
LicenseService *license.LicenseService
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAPIV1Service(profile *profile.Profile, store *store.Store, licenseService *license.LicenseService) *APIV1Service {
|
|
||||||
return &APIV1Service{
|
|
||||||
Profile: profile,
|
|
||||||
Store: store,
|
|
||||||
LicenseService: licenseService,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIV1Service) Start(apiGroup *echo.Group, secret string) {
|
|
||||||
apiV1Group := apiGroup.Group("/api/v1")
|
|
||||||
apiV1Group.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
||||||
return JWTMiddleware(s, next, secret)
|
|
||||||
})
|
|
||||||
s.registerWorkspaceRoutes(apiV1Group)
|
|
||||||
s.registerAuthRoutes(apiV1Group, secret)
|
|
||||||
s.registerUserRoutes(apiV1Group)
|
|
||||||
s.registerShortcutRoutes(apiV1Group)
|
|
||||||
s.registerAnalyticsRoutes(apiV1Group)
|
|
||||||
}
|
|
@ -1,39 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
|
||||||
|
|
||||||
storepb "github.com/yourselfhosted/slash/proto/gen/store"
|
|
||||||
"github.com/yourselfhosted/slash/server/profile"
|
|
||||||
"github.com/yourselfhosted/slash/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
type WorkspaceProfile struct {
|
|
||||||
Profile *profile.Profile `json:"profile"`
|
|
||||||
DisallowSignUp bool `json:"disallowSignUp"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIV1Service) registerWorkspaceRoutes(g *echo.Group) {
|
|
||||||
g.GET("/workspace/profile", func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
workspaceProfile := WorkspaceProfile{
|
|
||||||
Profile: s.Profile,
|
|
||||||
DisallowSignUp: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
enableSignUpSetting, err := s.Store.GetWorkspaceSetting(ctx, &store.FindWorkspaceSetting{
|
|
||||||
Key: storepb.WorkspaceSettingKey_WORKSAPCE_SETTING_ENABLE_SIGNUP,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find workspace setting, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
if enableSignUpSetting != nil {
|
|
||||||
workspaceProfile.DisallowSignUp = !enableSignUpSetting.GetEnableSignup()
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, workspaceProfile)
|
|
||||||
})
|
|
||||||
}
|
|
@ -1,34 +0,0 @@
|
|||||||
package v2
|
|
||||||
|
|
||||||
import "strings"
|
|
||||||
|
|
||||||
var allowedMethodsWhenUnauthorized = map[string]bool{
|
|
||||||
"/slash.api.v2.WorkspaceService/GetWorkspaceProfile": true,
|
|
||||||
"/slash.api.v2.WorkspaceService/GetWorkspaceSetting": true,
|
|
||||||
"/slash.api.v2.AuthService/SignIn": true,
|
|
||||||
"/slash.api.v2.AuthService/SignUp": true,
|
|
||||||
"/slash.api.v2.AuthService/SignOut": true,
|
|
||||||
"/memos.api.v2.AuthService/GetAuthStatus": true,
|
|
||||||
"/slash.api.v2.ShortcutService/GetShortcutByName": true,
|
|
||||||
"/slash.api.v2.CollectionService/GetCollectionByName": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// isUnauthorizeAllowedMethod returns true if the method is allowed to be called when the user is not authorized.
|
|
||||||
func isUnauthorizeAllowedMethod(methodName string) bool {
|
|
||||||
if strings.HasPrefix(methodName, "/grpc.reflection") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return allowedMethodsWhenUnauthorized[methodName]
|
|
||||||
}
|
|
||||||
|
|
||||||
var allowedMethodsOnlyForAdmin = map[string]bool{
|
|
||||||
"/slash.api.v2.UserService/CreateUser": true,
|
|
||||||
"/slash.api.v2.UserService/DeleteUser": true,
|
|
||||||
"/slash.api.v2.WorkspaceService/UpdateWorkspaceSetting": true,
|
|
||||||
"/slash.api.v2.SubscriptionService/UpdateSubscription": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// isOnlyForAdminAllowedMethod returns true if the method is allowed to be called only by admin.
|
|
||||||
func isOnlyForAdminAllowedMethod(methodName string) bool {
|
|
||||||
return allowedMethodsOnlyForAdmin[methodName]
|
|
||||||
}
|
|
@ -1,149 +0,0 @@
|
|||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
"google.golang.org/grpc"
|
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
"google.golang.org/grpc/metadata"
|
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
|
|
||||||
"github.com/yourselfhosted/slash/api/auth"
|
|
||||||
apiv2pb "github.com/yourselfhosted/slash/proto/gen/api/v2"
|
|
||||||
storepb "github.com/yourselfhosted/slash/proto/gen/store"
|
|
||||||
"github.com/yourselfhosted/slash/server/metric"
|
|
||||||
"github.com/yourselfhosted/slash/server/service/license"
|
|
||||||
"github.com/yourselfhosted/slash/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *APIV2Service) GetAuthStatus(ctx context.Context, _ *apiv2pb.GetAuthStatusRequest) (*apiv2pb.GetAuthStatusResponse, error) {
|
|
||||||
user, err := getCurrentUser(ctx, s.Store)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Unauthenticated, "failed to get current user: %v", err)
|
|
||||||
}
|
|
||||||
if user == nil {
|
|
||||||
return nil, status.Errorf(codes.Unauthenticated, "user not found")
|
|
||||||
}
|
|
||||||
return &apiv2pb.GetAuthStatusResponse{
|
|
||||||
User: convertUserFromStore(user),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIV2Service) SignIn(ctx context.Context, request *apiv2pb.SignInRequest) (*apiv2pb.SignInResponse, error) {
|
|
||||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
|
||||||
Email: &request.Email,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, fmt.Sprintf("failed to find user by email %s", request.Email))
|
|
||||||
}
|
|
||||||
if user == nil {
|
|
||||||
return nil, status.Errorf(codes.InvalidArgument, fmt.Sprintf("user not found with email %s", request.Email))
|
|
||||||
} else if user.RowStatus == store.Archived {
|
|
||||||
return nil, status.Errorf(codes.PermissionDenied, fmt.Sprintf("user has been archived with email %s", request.Email))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compare the stored hashed password, with the hashed version of the password that was received.
|
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(request.Password)); err != nil {
|
|
||||||
return nil, status.Errorf(codes.InvalidArgument, "unmatched email and password")
|
|
||||||
}
|
|
||||||
|
|
||||||
accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, time.Now().Add(auth.AccessTokenDuration), []byte(s.Secret))
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, fmt.Sprintf("failed to generate tokens, err: %s", err))
|
|
||||||
}
|
|
||||||
if err := s.UpsertAccessTokenToStore(ctx, user, accessToken, "user login"); err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, fmt.Sprintf("failed to upsert access token to store, err: %s", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := grpc.SetHeader(ctx, metadata.New(map[string]string{
|
|
||||||
"Set-Cookie": fmt.Sprintf("%s=%s; Path=/; Expires=%s; HttpOnly; SameSite=Strict", auth.AccessTokenCookieName, accessToken, time.Now().Add(auth.AccessTokenDuration).Format(time.RFC1123)),
|
|
||||||
})); err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to set grpc header, error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
metric.Enqueue("user sign in")
|
|
||||||
return &apiv2pb.SignInResponse{
|
|
||||||
User: convertUserFromStore(user),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIV2Service) SignUp(ctx context.Context, request *apiv2pb.SignUpRequest) (*apiv2pb.SignUpResponse, error) {
|
|
||||||
enableSignUpSetting, err := s.Store.GetWorkspaceSetting(ctx, &store.FindWorkspaceSetting{
|
|
||||||
Key: storepb.WorkspaceSettingKey_WORKSAPCE_SETTING_ENABLE_SIGNUP,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, fmt.Sprintf("failed to get workspace setting, err: %s", err))
|
|
||||||
}
|
|
||||||
if enableSignUpSetting != nil && !enableSignUpSetting.GetEnableSignup() {
|
|
||||||
return nil, status.Errorf(codes.PermissionDenied, "sign up is not allowed")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !s.LicenseService.IsFeatureEnabled(license.FeatureTypeUnlimitedAccounts) {
|
|
||||||
userList, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, fmt.Sprintf("failed to list users, err: %s", err))
|
|
||||||
}
|
|
||||||
if len(userList) >= 5 {
|
|
||||||
return nil, status.Errorf(codes.InvalidArgument, "maximum number of users reached")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(request.Password), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, fmt.Sprintf("failed to generate password hash, err: %s", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
create := &store.User{
|
|
||||||
Email: request.Email,
|
|
||||||
Nickname: request.Nickname,
|
|
||||||
PasswordHash: string(passwordHash),
|
|
||||||
}
|
|
||||||
existingUsers, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, fmt.Sprintf("failed to list users, err: %s", err))
|
|
||||||
}
|
|
||||||
// The first user to sign up is an admin by default.
|
|
||||||
if len(existingUsers) == 0 {
|
|
||||||
create.Role = store.RoleAdmin
|
|
||||||
} else {
|
|
||||||
create.Role = store.RoleUser
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := s.Store.CreateUser(ctx, create)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, fmt.Sprintf("failed to create user, err: %s", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, time.Now().Add(auth.AccessTokenDuration), []byte(s.Secret))
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, fmt.Sprintf("failed to generate tokens, err: %s", err))
|
|
||||||
}
|
|
||||||
if err := s.UpsertAccessTokenToStore(ctx, user, accessToken, "user login"); err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, fmt.Sprintf("failed to upsert access token to store, err: %s", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := grpc.SetHeader(ctx, metadata.New(map[string]string{
|
|
||||||
"Set-Cookie": fmt.Sprintf("%s=%s; Path=/; Expires=%s; HttpOnly; SameSite=Strict", auth.AccessTokenCookieName, accessToken, time.Now().Add(auth.AccessTokenDuration).Format(time.RFC1123)),
|
|
||||||
})); err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to set grpc header, error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
metric.Enqueue("user sign up")
|
|
||||||
return &apiv2pb.SignUpResponse{
|
|
||||||
User: convertUserFromStore(user),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*APIV2Service) SignOut(ctx context.Context, _ *apiv2pb.SignOutRequest) (*apiv2pb.SignOutResponse, error) {
|
|
||||||
// Set the cookie header to expire access token.
|
|
||||||
if err := grpc.SetHeader(ctx, metadata.New(map[string]string{
|
|
||||||
"Set-Cookie": fmt.Sprintf("%s=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Strict", auth.AccessTokenCookieName),
|
|
||||||
})); err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to set grpc header, error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &apiv2pb.SignOutResponse{}, nil
|
|
||||||
}
|
|
@ -1,33 +0,0 @@
|
|||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
apiv2pb "github.com/yourselfhosted/slash/proto/gen/api/v2"
|
|
||||||
"github.com/yourselfhosted/slash/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
func convertRowStatusFromStore(rowStatus store.RowStatus) apiv2pb.RowStatus {
|
|
||||||
switch rowStatus {
|
|
||||||
case store.Normal:
|
|
||||||
return apiv2pb.RowStatus_NORMAL
|
|
||||||
case store.Archived:
|
|
||||||
return apiv2pb.RowStatus_ARCHIVED
|
|
||||||
default:
|
|
||||||
return apiv2pb.RowStatus_ROW_STATUS_UNSPECIFIED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getCurrentUser(ctx context.Context, s *store.Store) (*store.User, error) {
|
|
||||||
userID, ok := ctx.Value(userIDContextKey).(int32)
|
|
||||||
if !ok {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
user, err := s.GetUser(ctx, &store.FindUser{
|
|
||||||
ID: &userID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return user, nil
|
|
||||||
}
|
|
@ -1,187 +0,0 @@
|
|||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
|
||||||
|
|
||||||
apiv2pb "github.com/yourselfhosted/slash/proto/gen/api/v2"
|
|
||||||
storepb "github.com/yourselfhosted/slash/proto/gen/store"
|
|
||||||
"github.com/yourselfhosted/slash/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *APIV2Service) ListMemos(ctx context.Context, _ *apiv2pb.ListMemosRequest) (*apiv2pb.ListMemosResponse, error) {
|
|
||||||
find := &store.FindMemo{}
|
|
||||||
memos, err := s.Store.ListMemos(ctx, find)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to fetch memo list, err: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
composedMemos := []*apiv2pb.Memo{}
|
|
||||||
for _, memo := range memos {
|
|
||||||
composedMemo, err := s.convertMemoFromStorepb(ctx, memo)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to convert memo, err: %v", err)
|
|
||||||
}
|
|
||||||
composedMemos = append(composedMemos, composedMemo)
|
|
||||||
}
|
|
||||||
|
|
||||||
response := &apiv2pb.ListMemosResponse{
|
|
||||||
Memos: composedMemos,
|
|
||||||
}
|
|
||||||
return response, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIV2Service) GetMemo(ctx context.Context, request *apiv2pb.GetMemoRequest) (*apiv2pb.GetMemoResponse, error) {
|
|
||||||
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
|
|
||||||
ID: &request.Id,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to get memo by ID: %v", err)
|
|
||||||
}
|
|
||||||
if memo == nil {
|
|
||||||
return nil, status.Errorf(codes.NotFound, "memo not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
composedMemo, err := s.convertMemoFromStorepb(ctx, memo)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to convert memo, err: %v", err)
|
|
||||||
}
|
|
||||||
response := &apiv2pb.GetMemoResponse{
|
|
||||||
Memo: composedMemo,
|
|
||||||
}
|
|
||||||
return response, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIV2Service) CreateMemo(ctx context.Context, request *apiv2pb.CreateMemoRequest) (*apiv2pb.CreateMemoResponse, error) {
|
|
||||||
userID := ctx.Value(userIDContextKey).(int32)
|
|
||||||
memo := &storepb.Memo{
|
|
||||||
CreatorId: userID,
|
|
||||||
Name: request.Memo.Name,
|
|
||||||
Title: request.Memo.Title,
|
|
||||||
Content: request.Memo.Content,
|
|
||||||
Tags: request.Memo.Tags,
|
|
||||||
Visibility: storepb.Visibility(request.Memo.Visibility),
|
|
||||||
}
|
|
||||||
memo, err := s.Store.CreateMemo(ctx, memo)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to create memo, err: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
composedMemo, err := s.convertMemoFromStorepb(ctx, memo)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to convert memo, err: %v", err)
|
|
||||||
}
|
|
||||||
response := &apiv2pb.CreateMemoResponse{
|
|
||||||
Memo: composedMemo,
|
|
||||||
}
|
|
||||||
return response, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIV2Service) UpdateMemo(ctx context.Context, request *apiv2pb.UpdateMemoRequest) (*apiv2pb.UpdateMemoResponse, error) {
|
|
||||||
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
|
||||||
return nil, status.Errorf(codes.InvalidArgument, "updateMask is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
userID := ctx.Value(userIDContextKey).(int32)
|
|
||||||
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
|
||||||
ID: &userID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to get current user, err: %v", err)
|
|
||||||
}
|
|
||||||
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
|
|
||||||
ID: &request.Memo.Id,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to get memo by ID: %v", err)
|
|
||||||
}
|
|
||||||
if memo == nil {
|
|
||||||
return nil, status.Errorf(codes.NotFound, "memo not found")
|
|
||||||
}
|
|
||||||
if memo.CreatorId != userID && currentUser.Role != store.RoleAdmin {
|
|
||||||
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
|
||||||
}
|
|
||||||
|
|
||||||
update := &store.UpdateMemo{
|
|
||||||
ID: memo.Id,
|
|
||||||
}
|
|
||||||
for _, path := range request.UpdateMask.Paths {
|
|
||||||
switch path {
|
|
||||||
case "name":
|
|
||||||
update.Name = &request.Memo.Name
|
|
||||||
case "title":
|
|
||||||
update.Title = &request.Memo.Title
|
|
||||||
case "content":
|
|
||||||
update.Content = &request.Memo.Content
|
|
||||||
case "tags":
|
|
||||||
tag := strings.Join(request.Memo.Tags, " ")
|
|
||||||
update.Tag = &tag
|
|
||||||
case "visibility":
|
|
||||||
visibility := store.Visibility(request.Memo.Visibility.String())
|
|
||||||
update.Visibility = &visibility
|
|
||||||
}
|
|
||||||
}
|
|
||||||
memo, err = s.Store.UpdateMemo(ctx, update)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to update memo, err: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
composedMemo, err := s.convertMemoFromStorepb(ctx, memo)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to convert memo, err: %v", err)
|
|
||||||
}
|
|
||||||
response := &apiv2pb.UpdateMemoResponse{
|
|
||||||
Memo: composedMemo,
|
|
||||||
}
|
|
||||||
return response, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIV2Service) DeleteMemo(ctx context.Context, request *apiv2pb.DeleteMemoRequest) (*apiv2pb.DeleteMemoResponse, error) {
|
|
||||||
userID := ctx.Value(userIDContextKey).(int32)
|
|
||||||
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
|
||||||
ID: &userID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to get current user, err: %v", err)
|
|
||||||
}
|
|
||||||
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
|
|
||||||
ID: &request.Id,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to get memo by ID: %v", err)
|
|
||||||
}
|
|
||||||
if memo == nil {
|
|
||||||
return nil, status.Errorf(codes.NotFound, "memo not found")
|
|
||||||
}
|
|
||||||
if memo.CreatorId != userID && currentUser.Role != store.RoleAdmin {
|
|
||||||
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
|
||||||
}
|
|
||||||
|
|
||||||
err = s.Store.DeleteMemo(ctx, &store.DeleteMemo{
|
|
||||||
ID: memo.Id,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to delete memo, err: %v", err)
|
|
||||||
}
|
|
||||||
response := &apiv2pb.DeleteMemoResponse{}
|
|
||||||
return response, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*APIV2Service) convertMemoFromStorepb(_ context.Context, memo *storepb.Memo) (*apiv2pb.Memo, error) {
|
|
||||||
return &apiv2pb.Memo{
|
|
||||||
Id: memo.Id,
|
|
||||||
CreatedTime: timestamppb.New(time.Unix(memo.CreatedTs, 0)),
|
|
||||||
UpdatedTime: timestamppb.New(time.Unix(memo.UpdatedTs, 0)),
|
|
||||||
CreatorId: memo.CreatorId,
|
|
||||||
Name: memo.Name,
|
|
||||||
Title: memo.Title,
|
|
||||||
Content: memo.Content,
|
|
||||||
Tags: memo.Tags,
|
|
||||||
Visibility: apiv2pb.Visibility(memo.Visibility),
|
|
||||||
}, nil
|
|
||||||
}
|
|
@ -1,30 +0,0 @@
|
|||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
|
|
||||||
apiv2pb "github.com/yourselfhosted/slash/proto/gen/api/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *APIV2Service) GetSubscription(ctx context.Context, _ *apiv2pb.GetSubscriptionRequest) (*apiv2pb.GetSubscriptionResponse, error) {
|
|
||||||
subscription, err := s.LicenseService.LoadSubscription(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to load subscription: %v", err)
|
|
||||||
}
|
|
||||||
return &apiv2pb.GetSubscriptionResponse{
|
|
||||||
Subscription: subscription,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIV2Service) UpdateSubscription(ctx context.Context, request *apiv2pb.UpdateSubscriptionRequest) (*apiv2pb.UpdateSubscriptionResponse, error) {
|
|
||||||
subscription, err := s.LicenseService.UpdateSubscription(ctx, request.LicenseKey)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to load subscription: %v", err)
|
|
||||||
}
|
|
||||||
return &apiv2pb.UpdateSubscriptionResponse{
|
|
||||||
Subscription: subscription,
|
|
||||||
}, nil
|
|
||||||
}
|
|
@ -1,135 +0,0 @@
|
|||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
|
|
||||||
apiv2pb "github.com/yourselfhosted/slash/proto/gen/api/v2"
|
|
||||||
storepb "github.com/yourselfhosted/slash/proto/gen/store"
|
|
||||||
"github.com/yourselfhosted/slash/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *APIV2Service) GetUserSetting(ctx context.Context, request *apiv2pb.GetUserSettingRequest) (*apiv2pb.GetUserSettingResponse, error) {
|
|
||||||
userSetting, err := getUserSetting(ctx, s.Store, request.Id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to get user setting: %v", err)
|
|
||||||
}
|
|
||||||
return &apiv2pb.GetUserSettingResponse{
|
|
||||||
UserSetting: userSetting,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIV2Service) UpdateUserSetting(ctx context.Context, request *apiv2pb.UpdateUserSettingRequest) (*apiv2pb.UpdateUserSettingResponse, error) {
|
|
||||||
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
|
||||||
return nil, status.Errorf(codes.InvalidArgument, "update mask is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
userID := ctx.Value(userIDContextKey).(int32)
|
|
||||||
for _, path := range request.UpdateMask.Paths {
|
|
||||||
if path == "locale" {
|
|
||||||
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
|
|
||||||
UserId: userID,
|
|
||||||
Key: storepb.UserSettingKey_USER_SETTING_LOCALE,
|
|
||||||
Value: &storepb.UserSetting_Locale{
|
|
||||||
Locale: convertUserSettingLocaleToStore(request.UserSetting.Locale),
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to update user setting: %v", err)
|
|
||||||
}
|
|
||||||
} else if path == "color_theme" {
|
|
||||||
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
|
|
||||||
UserId: userID,
|
|
||||||
Key: storepb.UserSettingKey_USER_SETTING_COLOR_THEME,
|
|
||||||
Value: &storepb.UserSetting_ColorTheme{
|
|
||||||
ColorTheme: convertUserSettingColorThemeToStore(request.UserSetting.ColorTheme),
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to update user setting: %v", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return nil, status.Errorf(codes.InvalidArgument, "invalid path: %s", path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
userSetting, err := getUserSetting(ctx, s.Store, request.Id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to get user setting: %v", err)
|
|
||||||
}
|
|
||||||
return &apiv2pb.UpdateUserSettingResponse{
|
|
||||||
UserSetting: userSetting,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getUserSetting(ctx context.Context, s *store.Store, userID int32) (*apiv2pb.UserSetting, error) {
|
|
||||||
userSettings, err := s.ListUserSettings(ctx, &store.FindUserSetting{
|
|
||||||
UserID: &userID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "failed to find user setting")
|
|
||||||
}
|
|
||||||
|
|
||||||
userSetting := &apiv2pb.UserSetting{
|
|
||||||
Id: userID,
|
|
||||||
Locale: apiv2pb.UserSetting_LOCALE_EN,
|
|
||||||
ColorTheme: apiv2pb.UserSetting_COLOR_THEME_SYSTEM,
|
|
||||||
}
|
|
||||||
for _, setting := range userSettings {
|
|
||||||
if setting.Key == storepb.UserSettingKey_USER_SETTING_LOCALE {
|
|
||||||
userSetting.Locale = convertUserSettingLocaleFromStore(setting.GetLocale())
|
|
||||||
} else if setting.Key == storepb.UserSettingKey_USER_SETTING_COLOR_THEME {
|
|
||||||
userSetting.ColorTheme = convertUserSettingColorThemeFromStore(setting.GetColorTheme())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return userSetting, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func convertUserSettingLocaleToStore(locale apiv2pb.UserSetting_Locale) storepb.LocaleUserSetting {
|
|
||||||
switch locale {
|
|
||||||
case apiv2pb.UserSetting_LOCALE_EN:
|
|
||||||
return storepb.LocaleUserSetting_LOCALE_USER_SETTING_EN
|
|
||||||
case apiv2pb.UserSetting_LOCALE_ZH:
|
|
||||||
return storepb.LocaleUserSetting_LOCALE_USER_SETTING_ZH
|
|
||||||
default:
|
|
||||||
return storepb.LocaleUserSetting_LOCALE_USER_SETTING_UNSPECIFIED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func convertUserSettingLocaleFromStore(locale storepb.LocaleUserSetting) apiv2pb.UserSetting_Locale {
|
|
||||||
switch locale {
|
|
||||||
case storepb.LocaleUserSetting_LOCALE_USER_SETTING_EN:
|
|
||||||
return apiv2pb.UserSetting_LOCALE_EN
|
|
||||||
case storepb.LocaleUserSetting_LOCALE_USER_SETTING_ZH:
|
|
||||||
return apiv2pb.UserSetting_LOCALE_ZH
|
|
||||||
default:
|
|
||||||
return apiv2pb.UserSetting_LOCALE_UNSPECIFIED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func convertUserSettingColorThemeToStore(colorTheme apiv2pb.UserSetting_ColorTheme) storepb.ColorThemeUserSetting {
|
|
||||||
switch colorTheme {
|
|
||||||
case apiv2pb.UserSetting_COLOR_THEME_SYSTEM:
|
|
||||||
return storepb.ColorThemeUserSetting_COLOR_THEME_USER_SETTING_SYSTEM
|
|
||||||
case apiv2pb.UserSetting_COLOR_THEME_LIGHT:
|
|
||||||
return storepb.ColorThemeUserSetting_COLOR_THEME_USER_SETTING_LIGHT
|
|
||||||
case apiv2pb.UserSetting_COLOR_THEME_DARK:
|
|
||||||
return storepb.ColorThemeUserSetting_COLOR_THEME_USER_SETTING_DARK
|
|
||||||
default:
|
|
||||||
return storepb.ColorThemeUserSetting_COLOR_THEME_USER_SETTING_UNSPECIFIED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func convertUserSettingColorThemeFromStore(colorTheme storepb.ColorThemeUserSetting) apiv2pb.UserSetting_ColorTheme {
|
|
||||||
switch colorTheme {
|
|
||||||
case storepb.ColorThemeUserSetting_COLOR_THEME_USER_SETTING_SYSTEM:
|
|
||||||
return apiv2pb.UserSetting_COLOR_THEME_SYSTEM
|
|
||||||
case storepb.ColorThemeUserSetting_COLOR_THEME_USER_SETTING_LIGHT:
|
|
||||||
return apiv2pb.UserSetting_COLOR_THEME_LIGHT
|
|
||||||
case storepb.ColorThemeUserSetting_COLOR_THEME_USER_SETTING_DARK:
|
|
||||||
return apiv2pb.UserSetting_COLOR_THEME_DARK
|
|
||||||
default:
|
|
||||||
return apiv2pb.UserSetting_COLOR_THEME_UNSPECIFIED
|
|
||||||
}
|
|
||||||
}
|
|
123
api/v2/v2.go
@ -1,123 +0,0 @@
|
|||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
|
||||||
"github.com/improbable-eng/grpc-web/go/grpcweb"
|
|
||||||
"github.com/labstack/echo/v4"
|
|
||||||
"google.golang.org/grpc"
|
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
|
||||||
"google.golang.org/grpc/reflection"
|
|
||||||
|
|
||||||
apiv2pb "github.com/yourselfhosted/slash/proto/gen/api/v2"
|
|
||||||
"github.com/yourselfhosted/slash/server/profile"
|
|
||||||
"github.com/yourselfhosted/slash/server/service/license"
|
|
||||||
"github.com/yourselfhosted/slash/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
type APIV2Service struct {
|
|
||||||
apiv2pb.UnimplementedWorkspaceServiceServer
|
|
||||||
apiv2pb.UnimplementedSubscriptionServiceServer
|
|
||||||
apiv2pb.UnimplementedAuthServiceServer
|
|
||||||
apiv2pb.UnimplementedUserServiceServer
|
|
||||||
apiv2pb.UnimplementedUserSettingServiceServer
|
|
||||||
apiv2pb.UnimplementedShortcutServiceServer
|
|
||||||
apiv2pb.UnimplementedCollectionServiceServer
|
|
||||||
apiv2pb.UnimplementedMemoServiceServer
|
|
||||||
|
|
||||||
Secret string
|
|
||||||
Profile *profile.Profile
|
|
||||||
Store *store.Store
|
|
||||||
LicenseService *license.LicenseService
|
|
||||||
|
|
||||||
grpcServer *grpc.Server
|
|
||||||
grpcServerPort int
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAPIV2Service(secret string, profile *profile.Profile, store *store.Store, licenseService *license.LicenseService, grpcServerPort int) *APIV2Service {
|
|
||||||
authProvider := NewGRPCAuthInterceptor(store, secret)
|
|
||||||
grpcServer := grpc.NewServer(
|
|
||||||
grpc.ChainUnaryInterceptor(
|
|
||||||
authProvider.AuthenticationInterceptor,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
apiV2Service := &APIV2Service{
|
|
||||||
Secret: secret,
|
|
||||||
Profile: profile,
|
|
||||||
Store: store,
|
|
||||||
LicenseService: licenseService,
|
|
||||||
grpcServer: grpcServer,
|
|
||||||
grpcServerPort: grpcServerPort,
|
|
||||||
}
|
|
||||||
|
|
||||||
apiv2pb.RegisterSubscriptionServiceServer(grpcServer, apiV2Service)
|
|
||||||
apiv2pb.RegisterWorkspaceServiceServer(grpcServer, apiV2Service)
|
|
||||||
apiv2pb.RegisterAuthServiceServer(grpcServer, apiV2Service)
|
|
||||||
apiv2pb.RegisterUserServiceServer(grpcServer, apiV2Service)
|
|
||||||
apiv2pb.RegisterUserSettingServiceServer(grpcServer, apiV2Service)
|
|
||||||
apiv2pb.RegisterShortcutServiceServer(grpcServer, apiV2Service)
|
|
||||||
apiv2pb.RegisterCollectionServiceServer(grpcServer, apiV2Service)
|
|
||||||
apiv2pb.RegisterMemoServiceServer(grpcServer, apiV2Service)
|
|
||||||
reflection.Register(grpcServer)
|
|
||||||
|
|
||||||
return apiV2Service
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIV2Service) GetGRPCServer() *grpc.Server {
|
|
||||||
return s.grpcServer
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisterGateway registers the gRPC-Gateway with the given Echo instance.
|
|
||||||
func (s *APIV2Service) RegisterGateway(ctx context.Context, e *echo.Echo) error {
|
|
||||||
// Create a client connection to the gRPC Server we just started.
|
|
||||||
// This is where the gRPC-Gateway proxies the requests.
|
|
||||||
conn, err := grpc.DialContext(
|
|
||||||
ctx,
|
|
||||||
fmt.Sprintf(":%d", s.grpcServerPort),
|
|
||||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
gwMux := runtime.NewServeMux()
|
|
||||||
if err := apiv2pb.RegisterSubscriptionServiceHandler(context.Background(), gwMux, conn); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := apiv2pb.RegisterWorkspaceServiceHandler(context.Background(), gwMux, conn); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := apiv2pb.RegisterAuthServiceHandler(context.Background(), gwMux, conn); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := apiv2pb.RegisterUserServiceHandler(context.Background(), gwMux, conn); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := apiv2pb.RegisterUserSettingServiceHandler(context.Background(), gwMux, conn); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := apiv2pb.RegisterShortcutServiceHandler(context.Background(), gwMux, conn); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := apiv2pb.RegisterCollectionServiceHandler(context.Background(), gwMux, conn); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := apiv2pb.RegisterMemoServiceHandler(context.Background(), gwMux, conn); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
e.Any("/api/v2/*", echo.WrapHandler(gwMux))
|
|
||||||
|
|
||||||
// GRPC web proxy.
|
|
||||||
options := []grpcweb.Option{
|
|
||||||
grpcweb.WithCorsForRegisteredEndpointsOnly(false),
|
|
||||||
grpcweb.WithOriginFunc(func(origin string) bool {
|
|
||||||
return true
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
wrappedGrpc := grpcweb.WrapServer(s.grpcServer, options...)
|
|
||||||
e.Any("/slash.api.v2.*", echo.WrapHandler(wrappedGrpc))
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
@ -1,146 +0,0 @@
|
|||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
|
|
||||||
apiv2pb "github.com/yourselfhosted/slash/proto/gen/api/v2"
|
|
||||||
storepb "github.com/yourselfhosted/slash/proto/gen/store"
|
|
||||||
"github.com/yourselfhosted/slash/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *APIV2Service) GetWorkspaceProfile(ctx context.Context, _ *apiv2pb.GetWorkspaceProfileRequest) (*apiv2pb.GetWorkspaceProfileResponse, error) {
|
|
||||||
profile := &apiv2pb.WorkspaceProfile{
|
|
||||||
Mode: s.Profile.Mode,
|
|
||||||
Version: s.Profile.Version,
|
|
||||||
Plan: apiv2pb.PlanType_FREE,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load subscription plan from license service.
|
|
||||||
subscription, err := s.LicenseService.GetSubscription(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to get subscription: %v", err)
|
|
||||||
}
|
|
||||||
profile.Plan = subscription.Plan
|
|
||||||
|
|
||||||
workspaceSetting, err := s.GetWorkspaceSetting(ctx, &apiv2pb.GetWorkspaceSettingRequest{})
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to get workspace setting: %v", err)
|
|
||||||
}
|
|
||||||
if workspaceSetting != nil {
|
|
||||||
setting := workspaceSetting.GetSetting()
|
|
||||||
profile.EnableSignup = setting.GetEnableSignup()
|
|
||||||
profile.CustomStyle = setting.GetCustomStyle()
|
|
||||||
profile.CustomScript = setting.GetCustomScript()
|
|
||||||
}
|
|
||||||
return &apiv2pb.GetWorkspaceProfileResponse{
|
|
||||||
Profile: profile,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIV2Service) GetWorkspaceSetting(ctx context.Context, _ *apiv2pb.GetWorkspaceSettingRequest) (*apiv2pb.GetWorkspaceSettingResponse, error) {
|
|
||||||
isAdmin := false
|
|
||||||
userID, ok := ctx.Value(userIDContextKey).(int32)
|
|
||||||
if ok {
|
|
||||||
user, err := s.Store.GetUser(ctx, &store.FindUser{ID: &userID})
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
|
|
||||||
}
|
|
||||||
if user.Role == store.RoleAdmin {
|
|
||||||
isAdmin = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
workspaceSettings, err := s.Store.ListWorkspaceSettings(ctx, &store.FindWorkspaceSetting{})
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to list workspace settings: %v", err)
|
|
||||||
}
|
|
||||||
workspaceSetting := &apiv2pb.WorkspaceSetting{
|
|
||||||
EnableSignup: true,
|
|
||||||
}
|
|
||||||
for _, v := range workspaceSettings {
|
|
||||||
if v.Key == storepb.WorkspaceSettingKey_WORKSAPCE_SETTING_ENABLE_SIGNUP {
|
|
||||||
workspaceSetting.EnableSignup = v.GetEnableSignup()
|
|
||||||
} else if v.Key == storepb.WorkspaceSettingKey_WORKSPACE_SETTING_INSTANCE_URL {
|
|
||||||
workspaceSetting.InstanceUrl = v.GetInstanceUrl()
|
|
||||||
} else if v.Key == storepb.WorkspaceSettingKey_WORKSPACE_SETTING_CUSTOM_STYLE {
|
|
||||||
workspaceSetting.CustomStyle = v.GetCustomStyle()
|
|
||||||
} else if v.Key == storepb.WorkspaceSettingKey_WORKSPACE_SETTING_CUSTOM_SCRIPT {
|
|
||||||
workspaceSetting.CustomScript = v.GetCustomScript()
|
|
||||||
} else if isAdmin {
|
|
||||||
// For some settings, only admin can get the value.
|
|
||||||
if v.Key == storepb.WorkspaceSettingKey_WORKSPACE_SETTING_LICENSE_KEY {
|
|
||||||
workspaceSetting.LicenseKey = v.GetLicenseKey()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &apiv2pb.GetWorkspaceSettingResponse{
|
|
||||||
Setting: workspaceSetting,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIV2Service) UpdateWorkspaceSetting(ctx context.Context, request *apiv2pb.UpdateWorkspaceSettingRequest) (*apiv2pb.UpdateWorkspaceSettingResponse, error) {
|
|
||||||
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
|
||||||
return nil, status.Errorf(codes.InvalidArgument, "update mask is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, path := range request.UpdateMask.Paths {
|
|
||||||
if path == "license_key" {
|
|
||||||
if _, err := s.Store.UpsertWorkspaceSetting(ctx, &storepb.WorkspaceSetting{
|
|
||||||
Key: storepb.WorkspaceSettingKey_WORKSPACE_SETTING_LICENSE_KEY,
|
|
||||||
Value: &storepb.WorkspaceSetting_LicenseKey{
|
|
||||||
LicenseKey: request.Setting.LicenseKey,
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to update workspace setting: %v", err)
|
|
||||||
}
|
|
||||||
} else if path == "enable_signup" {
|
|
||||||
if _, err := s.Store.UpsertWorkspaceSetting(ctx, &storepb.WorkspaceSetting{
|
|
||||||
Key: storepb.WorkspaceSettingKey_WORKSAPCE_SETTING_ENABLE_SIGNUP,
|
|
||||||
Value: &storepb.WorkspaceSetting_EnableSignup{
|
|
||||||
EnableSignup: request.Setting.EnableSignup,
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to update workspace setting: %v", err)
|
|
||||||
}
|
|
||||||
} else if path == "instance_url" {
|
|
||||||
if _, err := s.Store.UpsertWorkspaceSetting(ctx, &storepb.WorkspaceSetting{
|
|
||||||
Key: storepb.WorkspaceSettingKey_WORKSPACE_SETTING_INSTANCE_URL,
|
|
||||||
Value: &storepb.WorkspaceSetting_InstanceUrl{
|
|
||||||
InstanceUrl: request.Setting.InstanceUrl,
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to update workspace setting: %v", err)
|
|
||||||
}
|
|
||||||
} else if path == "custom_style" {
|
|
||||||
if _, err := s.Store.UpsertWorkspaceSetting(ctx, &storepb.WorkspaceSetting{
|
|
||||||
Key: storepb.WorkspaceSettingKey_WORKSPACE_SETTING_CUSTOM_STYLE,
|
|
||||||
Value: &storepb.WorkspaceSetting_CustomStyle{
|
|
||||||
CustomStyle: request.Setting.CustomStyle,
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to update workspace setting: %v", err)
|
|
||||||
}
|
|
||||||
} else if path == "custom_script" {
|
|
||||||
if _, err := s.Store.UpsertWorkspaceSetting(ctx, &storepb.WorkspaceSetting{
|
|
||||||
Key: storepb.WorkspaceSettingKey_WORKSPACE_SETTING_CUSTOM_SCRIPT,
|
|
||||||
Value: &storepb.WorkspaceSetting_CustomScript{
|
|
||||||
CustomScript: request.Setting.CustomScript,
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to update workspace setting: %v", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return nil, status.Errorf(codes.InvalidArgument, "invalid path: %s", path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getWorkspaceSettingResponse, err := s.GetWorkspaceSetting(ctx, &apiv2pb.GetWorkspaceSettingRequest{})
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to get workspace setting: %v", err)
|
|
||||||
}
|
|
||||||
return &apiv2pb.UpdateWorkspaceSettingResponse{
|
|
||||||
Setting: getWorkspaceSettingResponse.Setting,
|
|
||||||
}, nil
|
|
||||||
}
|
|
@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
@ -10,11 +11,9 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"go.uber.org/zap"
|
|
||||||
|
|
||||||
"github.com/yourselfhosted/slash/internal/log"
|
|
||||||
"github.com/yourselfhosted/slash/server"
|
"github.com/yourselfhosted/slash/server"
|
||||||
"github.com/yourselfhosted/slash/server/metric"
|
"github.com/yourselfhosted/slash/server/common"
|
||||||
"github.com/yourselfhosted/slash/server/profile"
|
"github.com/yourselfhosted/slash/server/profile"
|
||||||
"github.com/yourselfhosted/slash/store"
|
"github.com/yourselfhosted/slash/store"
|
||||||
"github.com/yourselfhosted/slash/store/db"
|
"github.com/yourselfhosted/slash/store/db"
|
||||||
@ -25,44 +24,43 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
serverProfile *profile.Profile
|
|
||||||
mode string
|
|
||||||
port int
|
|
||||||
data string
|
|
||||||
driver string
|
|
||||||
dsn string
|
|
||||||
enableMetric bool
|
|
||||||
|
|
||||||
rootCmd = &cobra.Command{
|
rootCmd = &cobra.Command{
|
||||||
Use: "slash",
|
Use: "slash",
|
||||||
Short: `An open source, self-hosted bookmarks and link sharing platform.`,
|
Short: `An open source, self-hosted platform for sharing and managing your most frequently used links.`,
|
||||||
Run: func(_cmd *cobra.Command, _args []string) {
|
Run: func(_ *cobra.Command, _ []string) {
|
||||||
|
serverProfile := &profile.Profile{
|
||||||
|
Mode: viper.GetString("mode"),
|
||||||
|
Port: viper.GetInt("port"),
|
||||||
|
Data: viper.GetString("data"),
|
||||||
|
DSN: viper.GetString("dsn"),
|
||||||
|
Driver: viper.GetString("driver"),
|
||||||
|
Version: common.GetCurrentVersion(viper.GetString("mode")),
|
||||||
|
}
|
||||||
|
if err := serverProfile.Validate(); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
dbDriver, err := db.NewDBDriver(serverProfile)
|
dbDriver, err := db.NewDBDriver(serverProfile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cancel()
|
cancel()
|
||||||
log.Error("failed to create db driver", zap.Error(err))
|
slog.Error("failed to create db driver", "error", err)
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := dbDriver.Migrate(ctx); err != nil {
|
|
||||||
cancel()
|
|
||||||
log.Error("failed to migrate db", zap.Error(err))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
storeInstance := store.New(dbDriver, serverProfile)
|
storeInstance := store.New(dbDriver, serverProfile)
|
||||||
|
if err := storeInstance.Migrate(ctx); err != nil {
|
||||||
|
cancel()
|
||||||
|
slog.Error("failed to migrate db", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
s, err := server.NewServer(ctx, serverProfile, storeInstance)
|
s, err := server.NewServer(ctx, serverProfile, storeInstance)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cancel()
|
cancel()
|
||||||
log.Error("failed to create server", zap.Error(err))
|
slog.Error("failed to create server", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if serverProfile.Metric {
|
|
||||||
// nolint
|
|
||||||
metric.NewMetricClient(s.Secret, *serverProfile)
|
|
||||||
}
|
|
||||||
|
|
||||||
c := make(chan os.Signal, 1)
|
c := make(chan os.Signal, 1)
|
||||||
// Trigger graceful shutdown on SIGINT or SIGTERM.
|
// Trigger graceful shutdown on SIGINT or SIGTERM.
|
||||||
// The default signal sent by the `kill` command is SIGTERM,
|
// The default signal sent by the `kill` command is SIGTERM,
|
||||||
@ -70,16 +68,16 @@ var (
|
|||||||
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
||||||
go func() {
|
go func() {
|
||||||
sig := <-c
|
sig := <-c
|
||||||
log.Info(fmt.Sprintf("%s received.\n", sig.String()))
|
slog.Info(fmt.Sprintf("%s received.\n", sig.String()))
|
||||||
s.Shutdown(ctx)
|
s.Shutdown(ctx)
|
||||||
cancel()
|
cancel()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
printGreetings()
|
printGreetings(serverProfile)
|
||||||
|
|
||||||
if err := s.Start(ctx); err != nil {
|
if err := s.Start(ctx); err != nil {
|
||||||
if err != http.ErrServerClosed {
|
if err != http.ErrServerClosed {
|
||||||
log.Error("failed to start server", zap.Error(err))
|
slog.Error("failed to start server", "error", err)
|
||||||
cancel()
|
cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -90,73 +88,46 @@ var (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func Execute() error {
|
|
||||||
defer log.Sync()
|
|
||||||
return rootCmd.Execute()
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
cobra.OnInitialize(initConfig)
|
|
||||||
|
|
||||||
rootCmd.PersistentFlags().StringVarP(&mode, "mode", "m", "demo", `mode of server, can be "prod" or "dev" or "demo"`)
|
|
||||||
rootCmd.PersistentFlags().IntVarP(&port, "port", "p", 8082, "port of server")
|
|
||||||
rootCmd.PersistentFlags().StringVarP(&data, "data", "d", "", "data directory")
|
|
||||||
rootCmd.PersistentFlags().StringVarP(&driver, "driver", "", "", "database driver")
|
|
||||||
rootCmd.PersistentFlags().StringVarP(&dsn, "dsn", "", "", "database source name(aka. DSN)")
|
|
||||||
rootCmd.PersistentFlags().BoolVarP(&enableMetric, "metric", "", true, "allow metric collection")
|
|
||||||
|
|
||||||
err := viper.BindPFlag("mode", rootCmd.PersistentFlags().Lookup("mode"))
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
err = viper.BindPFlag("port", rootCmd.PersistentFlags().Lookup("port"))
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
err = viper.BindPFlag("data", rootCmd.PersistentFlags().Lookup("data"))
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
err = viper.BindPFlag("driver", rootCmd.PersistentFlags().Lookup("driver"))
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
err = viper.BindPFlag("dsn", rootCmd.PersistentFlags().Lookup("dsn"))
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
err = viper.BindPFlag("metric", rootCmd.PersistentFlags().Lookup("metric"))
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
viper.SetDefault("mode", "demo")
|
viper.SetDefault("mode", "demo")
|
||||||
viper.SetDefault("port", 8082)
|
|
||||||
viper.SetDefault("driver", "sqlite")
|
viper.SetDefault("driver", "sqlite")
|
||||||
viper.SetDefault("metric", true)
|
viper.SetDefault("port", 8082)
|
||||||
|
|
||||||
|
rootCmd.PersistentFlags().String("mode", "demo", `mode of server, can be "prod" or "dev" or "demo"`)
|
||||||
|
rootCmd.PersistentFlags().String("addr", "", "address of server")
|
||||||
|
rootCmd.PersistentFlags().Int("port", 8082, "port of server")
|
||||||
|
rootCmd.PersistentFlags().String("data", "", "data directory")
|
||||||
|
rootCmd.PersistentFlags().String("driver", "sqlite", "database driver")
|
||||||
|
rootCmd.PersistentFlags().String("dsn", "", "database source name(aka. DSN)")
|
||||||
|
|
||||||
|
if err := viper.BindPFlag("mode", rootCmd.PersistentFlags().Lookup("mode")); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
if err := viper.BindPFlag("port", rootCmd.PersistentFlags().Lookup("port")); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
if err := viper.BindPFlag("data", rootCmd.PersistentFlags().Lookup("data")); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
if err := viper.BindPFlag("driver", rootCmd.PersistentFlags().Lookup("driver")); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
if err := viper.BindPFlag("dsn", rootCmd.PersistentFlags().Lookup("dsn")); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
viper.SetEnvPrefix("slash")
|
viper.SetEnvPrefix("slash")
|
||||||
}
|
|
||||||
|
|
||||||
func initConfig() {
|
|
||||||
viper.AutomaticEnv()
|
viper.AutomaticEnv()
|
||||||
var err error
|
|
||||||
serverProfile, err = profile.GetProfile()
|
|
||||||
if err != nil {
|
|
||||||
log.Error("failed to get profile", zap.Error(err))
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func printGreetings(serverProfile *profile.Profile) {
|
||||||
println("---")
|
println("---")
|
||||||
println("Server profile")
|
println("Server profile")
|
||||||
println("dsn:", serverProfile.DSN)
|
println("dsn:", serverProfile.DSN)
|
||||||
println("port:", serverProfile.Port)
|
println("port:", serverProfile.Port)
|
||||||
println("mode:", serverProfile.Mode)
|
println("mode:", serverProfile.Mode)
|
||||||
println("version:", serverProfile.Version)
|
println("version:", serverProfile.Version)
|
||||||
println("metric:", serverProfile.Metric)
|
|
||||||
println("---")
|
println("---")
|
||||||
}
|
|
||||||
|
|
||||||
func printGreetings() {
|
|
||||||
println(greetingBanner)
|
println(greetingBanner)
|
||||||
fmt.Printf("Version %s has been started on port %d\n", serverProfile.Version, serverProfile.Port)
|
fmt.Printf("Version %s has been started on port %d\n", serverProfile.Version, serverProfile.Port)
|
||||||
println("---")
|
println("---")
|
||||||
@ -166,8 +137,7 @@ func printGreetings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
err := Execute()
|
if err := rootCmd.Execute(); err != nil {
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 134 KiB |
Before Width: | Height: | Size: 836 KiB After Width: | Height: | Size: 613 KiB |
Before Width: | Height: | Size: 155 KiB |
Before Width: | Height: | Size: 154 KiB |
Before Width: | Height: | Size: 77 KiB |
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 69 KiB |
BIN
docs/assets/getting-started/github-sso.png
Normal file
After Width: | Height: | Size: 180 KiB |
BIN
docs/assets/getting-started/sso-setting.png
Normal file
After Width: | Height: | Size: 144 KiB |
Before Width: | Height: | Size: 19 KiB |
BIN
docs/assets/wechat.png
Normal file
After Width: | Height: | Size: 125 KiB |
37
docs/getting-started/sso.md
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# Single Sign-On(SSO)
|
||||||
|
|
||||||
|
> **Note**: This feature is only available in the **Enterprise** plan.
|
||||||
|
|
||||||
|
**Single Sign-On (SSO)** is an authentication method that enables users to securely authenticate with multiple applications and websites by using just one set of credentials.
|
||||||
|
|
||||||
|
Slash supports SSO integration with **OAuth 2.0** standard.
|
||||||
|
|
||||||
|
## Create a new SSO provider
|
||||||
|
|
||||||
|
As an Admin user, you can create a new SSO provider in Setting > Workspace settings > SSO.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
For example, to integrate with GitHub, you might need to fill in the following fields:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Identity provider information
|
||||||
|
|
||||||
|
The information is the base concept of OAuth 2.0 and comes from your provider.
|
||||||
|
|
||||||
|
- **Client ID** is a public identifier of the custom provider;
|
||||||
|
- **Client Secret** is the OAuth2 client secret from identity provider;
|
||||||
|
- **Authorization endpoint** is the custom provider's OAuth2 login page address;
|
||||||
|
- **Token endpoint** is the API address for obtaining access token;
|
||||||
|
- **User endpoint** URL is the API address for obtaining user information by access token;
|
||||||
|
- **Scopes** is the scope parameter carried when accessing the OAuth2 URL, which is filled in according to the custom provider;
|
||||||
|
|
||||||
|
### User information mapping
|
||||||
|
|
||||||
|
For different providers, the structures returned by their user information API are usually not the same. In order to know how to map the user information from an provider into user fields, you need to fill the user information mapping form.
|
||||||
|
|
||||||
|
Slash will use the mapping to import the user profile fields when creating new accounts. The most important user field mapping is the identifier which is used to identify the Slash account associated with the OAuth 2.0 login.
|
||||||
|
|
||||||
|
- **Identifier** is the field name of primary email in 3rd-party user info;
|
||||||
|
- **Display name** is the field name of display name in 3rd-party user info (optional);
|
44
docs/getting-started/subscription.md
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# Subscription
|
||||||
|
|
||||||
|
Slash is an open source, self-hosted platform for sharing and managing your most frequently used links. Easily create customizable, human-readable shortcuts to streamline your link management. Our source code is available and accessible on GitHub so anyone can get it, inspect it and review it.
|
||||||
|
|
||||||
|
## Plans
|
||||||
|
|
||||||
|
### Free
|
||||||
|
|
||||||
|
The Free plan is designed for personal use not for commercial use. It allows you to create up to 100 shortcuts and invite up to 5 members.
|
||||||
|
|
||||||
|
### Pro
|
||||||
|
|
||||||
|
The Pro plan is designed for teams and businesses. It allows you to create unlimited shortcuts and invite unlimited members. It also includes priority support. The Pro plan is $4 per month.
|
||||||
|
|
||||||
|
### Team
|
||||||
|
|
||||||
|
The Team plan is designed for teams that need more than the Pro plan. It allows you to use Single Sign-On(SSO) and other advanced features. If you need a team plan, please contact us at `yourselfhosted@gmail.com`.
|
||||||
|
|
||||||
|
## Using a License Key
|
||||||
|
|
||||||
|
After purchasing a Pro or Team plan, you will receive a license key. You can use the license key to activate your plan. Here is how to do it:
|
||||||
|
|
||||||
|
1. Log in to your Slash instance as an Admin user.
|
||||||
|
2. Go to Settings > Subscription. `https://your-slash-instance.com/setting/subscription`
|
||||||
|
3. You will see a form to enter your license key. Enter your license key and click the **Upload license** button.
|
||||||
|
4. If the license key is valid, your plan will be activated.
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
### Can I use the Free plan in my team?
|
||||||
|
|
||||||
|
Of course you can. In the free plan, you can invite up to 5 members to your team. If you need more, you should upgrade to the Pro plan.
|
||||||
|
|
||||||
|
### How many devices can the license key be used on?
|
||||||
|
|
||||||
|
It's unlimited for now, but please do not abuse it.
|
||||||
|
|
||||||
|
### Can I get a refund if Slash doesn't meet my needs?
|
||||||
|
|
||||||
|
Yes, absolutely! You can contact us with `yourselfhosted@gmail.com`. I will refund you as soon as possible.
|
||||||
|
|
||||||
|
### Is there a Lifetime license?
|
||||||
|
|
||||||
|
As software requires someone to maintain it, so we won't sell a lifetime service, since humans are not immortal yet. But if you really want it, please contact us `yourselfhosted@gmail.com`.
|
@ -10,34 +10,23 @@ For Chromuim based browsers, you can install the extension from the [Chrome Web
|
|||||||
|
|
||||||
For Firefox, you can install the extension from the [Firefox Add-ons](https://addons.mozilla.org/en-US/firefox/addon/your-slash/).
|
For Firefox, you can install the extension from the [Firefox Add-ons](https://addons.mozilla.org/en-US/firefox/addon/your-slash/).
|
||||||
|
|
||||||
### Generate an access token
|
### Prerequisites
|
||||||
|
|
||||||
1. Go to your Slash instance and sign in with your account.
|
- You need to have a Slash instance running.
|
||||||
|
- Sign in with your account on the Slash instance.
|
||||||
2. Go to the settings page and click on the "Create" button to create an access token.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
3. Copy the access token and save it somewhere safe.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### Configure the extension
|
### Configure the extension
|
||||||
|
|
||||||
|
The extension needs to know the instance url of your Slash. You can configure it by following the steps below:
|
||||||
|
|
||||||
1. Click on the extension icon and click on the "Settings" button.
|
1. Click on the extension icon and click on the "Settings" button.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
2. Enter your Slash's domain and paste the access token you generated in the previous step.
|
2. Enter the instance url of your Slash and then "Save".
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
3. Click on the "Save" button to save the settings.
|
|
||||||
|
|
||||||
4. Click on the extension icon again, you will see a list of your shortcuts.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### Use your shortcuts in the search bar
|
### Use your shortcuts in the search bar
|
||||||
|
|
||||||
You can use your shortcuts in the search bar of your browser. For example, if you have a shortcut named `gh` for [GitHub](https://github.com), you can type `s/gh` in the search bar and press `Enter` to go to [GitHub](https://github.com).
|
You can use your shortcuts in the search bar of your browser. For example, if you have a shortcut named `gh` for [GitHub](https://github.com), you can type `s/gh` in the search bar and press `Enter` to go to [GitHub](https://github.com).
|
||||||
|
@ -50,10 +50,32 @@ docker compose up -d
|
|||||||
|
|
||||||
This will start Slash in the background and expose it on port `5231`. Data is stored in Docker Volume `slash_slash`. You can customize the port and backup your volume.
|
This will start Slash in the background and expose it on port `5231`. Data is stored in Docker Volume `slash_slash`. You can customize the port and backup your volume.
|
||||||
|
|
||||||
### Upgrade
|
## Use PostgreSQL as Database
|
||||||
|
|
||||||
```bash
|
Slash supports the following database types:
|
||||||
cd /opt/slash
|
|
||||||
docker compose pull
|
- SQLite (default)
|
||||||
docker compose up -d
|
- PostgreSQL
|
||||||
|
|
||||||
|
### Using PostgreSQL
|
||||||
|
|
||||||
|
To switch to PostgreSQL, you can use the following steps:
|
||||||
|
|
||||||
|
- **--driver** _postgres_ : This argument specifies that Slash should use the `postgres` driver instead of the default `sqlite`.
|
||||||
|
|
||||||
|
- **--dsn** _postgresql://postgres:PASSWORD@localhost:5432/slash_ : Provides the connection details for your PostgreSQL server.
|
||||||
|
|
||||||
|
You can start Slash with Docker using the following command:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
docker run -d --name slash --publish 5231:5231 --volume ~/.slash/:/var/opt/slash yourselfhosted/slash:latest --driver postgres --dsn 'postgresql://postgres:PASSWORD@localhost:5432/slash'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Additionally, you can set these configurations via environment variables:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
SLASH_DRIVER=postgres
|
||||||
|
SLASH_DSN=postgresql://root:password@localhost:5432/slash
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that if the PostgreSQL server is not configured to support SSL connections you will need to add `?sslmode=disable` to the DSN.
|
||||||
|
7
docs/privacy-policy.md
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
# Privacy Policy
|
||||||
|
|
||||||
|
Slash does not collect, store, or share any data from you.
|
||||||
|
|
||||||
|
You can use our application freely and without concern about your personal data being tracked or stored.
|
||||||
|
|
||||||
|
Our primary goal is to provide you with a secure and private experience.
|
21
frontend/extension/LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 yourselfhosted
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 6.4 KiB |
@ -1,52 +1,44 @@
|
|||||||
{
|
{
|
||||||
"name": "slash-extension",
|
"name": "slash-extension",
|
||||||
"displayName": "Slash",
|
"displayName": "Slash",
|
||||||
"version": "1.0.4",
|
"version": "1.0.11",
|
||||||
"description": "An open source, self-hosted bookmarks and link sharing platform. Save and share your links very easily.",
|
"description": "An open source, self-hosted platform for sharing and managing your most frequently used links. Save and share your links very easily.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "plasmo dev",
|
"dev": "plasmo dev",
|
||||||
"build": "plasmo build",
|
"build": "plasmo build",
|
||||||
"package": "plasmo package",
|
"package": "plasmo package",
|
||||||
"lint": "eslint --ext .js,.ts,.tsx, src",
|
"lint": "eslint --ext .js,.ts,.tsx, src",
|
||||||
"lint-fix": "eslint --ext .js,.ts,.tsx, src --fix",
|
"lint-fix": "eslint --ext .js,.ts,.tsx, src --fix"
|
||||||
"type-gen": "cd ../../proto && buf generate"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.11.3",
|
"@emotion/react": "^11.13.3",
|
||||||
"@emotion/styled": "^11.11.0",
|
"@emotion/styled": "^11.13.0",
|
||||||
"@mui/joy": "5.0.0-beta.23",
|
"@mui/joy": "5.0.0-beta.48",
|
||||||
"@plasmohq/storage": "^1.9.0",
|
"@plasmohq/storage": "^1.12.0",
|
||||||
"axios": "^1.6.5",
|
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"lodash-es": "^4.17.21",
|
"lucide-react": "^0.454.0",
|
||||||
"lucide-react": "^0.312.0",
|
"plasmo": "^0.89.3",
|
||||||
"plasmo": "^0.83.1",
|
"react": "^18.3.1",
|
||||||
"react": "^18.2.0",
|
"react-dom": "^18.3.1",
|
||||||
"react-dom": "^18.2.0",
|
"react-hot-toast": "^2.4.1"
|
||||||
"react-hot-toast": "^2.4.1",
|
|
||||||
"zustand": "^4.5.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@bufbuild/buf": "^1.28.1",
|
|
||||||
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
||||||
"@types/chrome": "^0.0.241",
|
"@types/chrome": "^0.0.280",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/node": "^22.8.6",
|
||||||
"@types/node": "^20.11.5",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react": "^18.2.48",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@types/react-dom": "^18.2.18",
|
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
"@typescript-eslint/parser": "^7.18.0",
|
||||||
"@typescript-eslint/parser": "^6.19.0",
|
"autoprefixer": "^10.4.20",
|
||||||
"autoprefixer": "^10.4.17",
|
"eslint": "^8.57.1",
|
||||||
"eslint": "^8.56.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-config-prettier": "^8.10.0",
|
"eslint-plugin-prettier": "^5.2.1",
|
||||||
"eslint-plugin-prettier": "^4.2.1",
|
"eslint-plugin-react": "^7.37.2",
|
||||||
"eslint-plugin-react": "^7.33.2",
|
"postcss": "^8.4.47",
|
||||||
"long": "^5.2.3",
|
"prettier": "^3.3.3",
|
||||||
"postcss": "^8.4.33",
|
"tailwindcss": "^3.4.14",
|
||||||
"prettier": "^2.8.8",
|
"typescript": "^5.6.3"
|
||||||
"protobufjs": "^7.2.6",
|
|
||||||
"tailwindcss": "^3.4.1",
|
|
||||||
"typescript": "^5.3.3"
|
|
||||||
},
|
},
|
||||||
"manifest": {
|
"manifest": {
|
||||||
"permissions": [
|
"permissions": [
|
||||||
|
10720
frontend/extension/pnpm-lock.yaml
generated
@ -12,12 +12,13 @@ chrome.webRequest.onBeforeRequest.addListener(
|
|||||||
|
|
||||||
const shortcutName = getShortcutNameFromUrl(param.url);
|
const shortcutName = getShortcutNameFromUrl(param.url);
|
||||||
if (shortcutName) {
|
if (shortcutName) {
|
||||||
const instanceUrl = (await storage.getItem<string>("domain")) || "";
|
const instanceUrl = (await storage.getItem<string>("instance_url")) || "";
|
||||||
return chrome.tabs.update({ url: `${instanceUrl}/s/${shortcutName}` });
|
const url = new URL(`/s/${shortcutName}`, instanceUrl);
|
||||||
|
return chrome.tabs.update({ url: url.toString() });
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
},
|
},
|
||||||
{ urls: ["*://s/*", "*://*/search*"] }
|
{ urls: ["*://s/*", "*://*/search*"] },
|
||||||
);
|
);
|
||||||
|
|
||||||
const getShortcutNameFromUrl = (urlString: string) => {
|
const getShortcutNameFromUrl = (urlString: string) => {
|
||||||
@ -30,19 +31,19 @@ const getShortcutNameFromUrl = (urlString: string) => {
|
|||||||
|
|
||||||
const getShortcutNameFromSearchUrl = (urlString: string) => {
|
const getShortcutNameFromSearchUrl = (urlString: string) => {
|
||||||
const url = new URL(urlString);
|
const url = new URL(urlString);
|
||||||
if ((url.hostname === "www.google.com" || url.hostname === "www.bing.com") && url.pathname === "/search") {
|
if ((url.hostname.endsWith("google.com") || url.hostname.endsWith("bing.com")) && url.pathname === "/search") {
|
||||||
const params = new URLSearchParams(url.search);
|
const params = new URLSearchParams(url.search);
|
||||||
const shortcutName = params.get("q");
|
const shortcutName = params.get("q");
|
||||||
if (typeof shortcutName === "string" && shortcutName.startsWith("s/")) {
|
if (typeof shortcutName === "string" && shortcutName.startsWith("s/")) {
|
||||||
return shortcutName.slice(2);
|
return shortcutName.slice(2);
|
||||||
}
|
}
|
||||||
} else if (url.hostname === "www.baidu.com" && url.pathname === "/s") {
|
} else if (url.hostname.endsWith("baidu.com") && url.pathname === "/s") {
|
||||||
const params = new URLSearchParams(url.search);
|
const params = new URLSearchParams(url.search);
|
||||||
const shortcutName = params.get("wd");
|
const shortcutName = params.get("wd");
|
||||||
if (typeof shortcutName === "string" && shortcutName.startsWith("s/")) {
|
if (typeof shortcutName === "string" && shortcutName.startsWith("s/")) {
|
||||||
return shortcutName.slice(2);
|
return shortcutName.slice(2);
|
||||||
}
|
}
|
||||||
} else if (url.hostname === "duckduckgo.com" && url.pathname === "/") {
|
} else if (url.hostname.endsWith("duckduckgo.com") && url.pathname === "/") {
|
||||||
const params = new URLSearchParams(url.search);
|
const params = new URLSearchParams(url.search);
|
||||||
const shortcutName = params.get("q");
|
const shortcutName = params.get("q");
|
||||||
if (typeof shortcutName === "string" && shortcutName.startsWith("s/")) {
|
if (typeof shortcutName === "string" && shortcutName.startsWith("s/")) {
|
||||||
|
@ -1,176 +0,0 @@
|
|||||||
import { Button, IconButton, Input, Modal, ModalDialog } from "@mui/joy";
|
|
||||||
import { useStorage } from "@plasmohq/storage/hook";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { toast } from "react-hot-toast";
|
|
||||||
import useShortcutStore from "@/store/shortcut";
|
|
||||||
import { Visibility } from "@/types/proto/api/v2/common";
|
|
||||||
import { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
|
||||||
import Icon from "./Icon";
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
name: string;
|
|
||||||
title: string;
|
|
||||||
link: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CreateShortcutButton = () => {
|
|
||||||
const [instanceUrl] = useStorage("domain");
|
|
||||||
const [accessToken] = useStorage("access_token");
|
|
||||||
const shortcutStore = useShortcutStore();
|
|
||||||
const [state, setState] = useState<State>({
|
|
||||||
name: "",
|
|
||||||
title: "",
|
|
||||||
link: "",
|
|
||||||
});
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [showModal, setShowModal] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (showModal) {
|
|
||||||
document.body.style.height = "384px";
|
|
||||||
} else {
|
|
||||||
document.body.style.height = "auto";
|
|
||||||
}
|
|
||||||
}, [showModal]);
|
|
||||||
|
|
||||||
const handleCreateShortcutButtonClick = async () => {
|
|
||||||
chrome.tabs.query({ active: true, currentWindow: true }, async (tabs) => {
|
|
||||||
if (tabs.length === 0) {
|
|
||||||
toast.error("No active tab found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const tab = tabs[0];
|
|
||||||
setState((state) => ({
|
|
||||||
...state,
|
|
||||||
name: "",
|
|
||||||
title: tab.title || "",
|
|
||||||
link: tab.url || "",
|
|
||||||
}));
|
|
||||||
setShowModal(true);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateRandomName = () => {
|
|
||||||
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
||||||
let name = "";
|
|
||||||
for (let i = 0; i < 8; i++) {
|
|
||||||
name += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
||||||
}
|
|
||||||
setState((state) => ({
|
|
||||||
...state,
|
|
||||||
name,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setState((state) => ({
|
|
||||||
...state,
|
|
||||||
name: e.target.value,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTitleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setState((state) => ({
|
|
||||||
...state,
|
|
||||||
title: e.target.value,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLinkInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setState((state) => ({
|
|
||||||
...state,
|
|
||||||
link: e.target.value,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveBtnClick = async () => {
|
|
||||||
if (isLoading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!state.name) {
|
|
||||||
toast.error("Name is required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
await shortcutStore.createShortcut(
|
|
||||||
instanceUrl,
|
|
||||||
accessToken,
|
|
||||||
Shortcut.fromPartial({
|
|
||||||
name: state.name,
|
|
||||||
title: state.title,
|
|
||||||
link: state.link,
|
|
||||||
visibility: Visibility.PUBLIC,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
toast.success("Shortcut created successfully");
|
|
||||||
setShowModal(false);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(error);
|
|
||||||
toast.error(error.details);
|
|
||||||
}
|
|
||||||
setIsLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<IconButton color="primary" variant="solid" size="sm" onClick={() => handleCreateShortcutButtonClick()}>
|
|
||||||
<Icon.Plus className="w-5 h-auto" />
|
|
||||||
</IconButton>
|
|
||||||
|
|
||||||
<Modal container={() => document.body} open={showModal} onClose={() => setShowModal(false)}>
|
|
||||||
<ModalDialog className="w-3/4">
|
|
||||||
<div className="w-full flex flex-row justify-between items-center mb-2">
|
|
||||||
<span className="text-base font-medium">Create Shortcut</span>
|
|
||||||
<Button size="sm" variant="plain" onClick={() => setShowModal(false)}>
|
|
||||||
<Icon.X className="w-5 h-auto text-gray-600" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-x-hidden w-full flex flex-col justify-start items-center">
|
|
||||||
<div className="w-full flex flex-row justify-start items-center mb-2">
|
|
||||||
<span className="block w-12 mr-2 shrink-0">Name</span>
|
|
||||||
<Input
|
|
||||||
className="grow"
|
|
||||||
type="text"
|
|
||||||
placeholder="Unique shortcut name"
|
|
||||||
value={state.name}
|
|
||||||
onChange={handleNameInputChange}
|
|
||||||
endDecorator={
|
|
||||||
<IconButton size="sm" onClick={generateRandomName}>
|
|
||||||
<Icon.RefreshCcw className="w-4 h-auto cursor-pointer" />
|
|
||||||
</IconButton>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-full flex flex-row justify-start items-center mb-2">
|
|
||||||
<span className="block w-12 mr-2 shrink-0">Title</span>
|
|
||||||
<Input className="grow" type="text" placeholder="Shortcut title" value={state.title} onChange={handleTitleInputChange} />
|
|
||||||
</div>
|
|
||||||
<div className="w-full flex flex-row justify-start items-center mb-2">
|
|
||||||
<span className="block w-12 mr-2 shrink-0">Link</span>
|
|
||||||
<Input
|
|
||||||
className="grow"
|
|
||||||
type="text"
|
|
||||||
placeholder="e.g., https://github.com/yourselfhosted/slash"
|
|
||||||
value={state.link}
|
|
||||||
onChange={handleLinkInputChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full flex flex-row justify-end items-center mt-2 space-x-2">
|
|
||||||
<Button color="neutral" variant="plain" onClick={() => setShowModal(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button color="primary" disabled={isLoading} loading={isLoading} onClick={handleSaveBtnClick}>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ModalDialog>
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CreateShortcutButton;
|
|
@ -1,12 +1,12 @@
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import LogoBase64 from "data-base64:../../assets/icon.png";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Logo = ({ className }: Props) => {
|
const Logo = ({ className }: Props) => {
|
||||||
return <img className={classNames("rounded-full", className)} src={LogoBase64} alt="" />;
|
return <Icon.CircleSlash className={classNames("dark:text-gray-500", className)} strokeWidth={1.5} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Logo;
|
export default Logo;
|
||||||
|
@ -1,37 +0,0 @@
|
|||||||
import { IconButton } from "@mui/joy";
|
|
||||||
import { useStorage } from "@plasmohq/storage/hook";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { toast } from "react-hot-toast";
|
|
||||||
import useShortcutStore from "@/store/shortcut";
|
|
||||||
import Icon from "./Icon";
|
|
||||||
|
|
||||||
const PullShortcutsButton = () => {
|
|
||||||
const [instanceUrl] = useStorage("domain");
|
|
||||||
const [accessToken] = useStorage("access_token");
|
|
||||||
const shortcutStore = useShortcutStore();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (instanceUrl && accessToken) {
|
|
||||||
handlePullShortcuts(true);
|
|
||||||
}
|
|
||||||
}, [instanceUrl, accessToken]);
|
|
||||||
|
|
||||||
const handlePullShortcuts = async (silence = false) => {
|
|
||||||
try {
|
|
||||||
await shortcutStore.fetchShortcutList(instanceUrl, accessToken);
|
|
||||||
if (!silence) {
|
|
||||||
toast.success("Shortcuts pulled");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error("Failed to pull shortcuts, error: " + error.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IconButton color="neutral" variant="plain" size="sm" onClick={() => handlePullShortcuts()}>
|
|
||||||
<Icon.RefreshCcw className="w-4 h-auto" />
|
|
||||||
</IconButton>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PullShortcutsButton;
|
|
@ -1,66 +0,0 @@
|
|||||||
import { useStorage } from "@plasmohq/storage/hook";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { getFaviconWithGoogleS2 } from "@/helpers/utils";
|
|
||||||
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
|
||||||
import Icon from "./Icon";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
shortcut: Shortcut;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ShortcutView = (props: Props) => {
|
|
||||||
const { shortcut } = props;
|
|
||||||
const [domain] = useStorage<string>("domain", "");
|
|
||||||
const favicon = getFaviconWithGoogleS2(shortcut.link);
|
|
||||||
|
|
||||||
const handleShortcutLinkClick = () => {
|
|
||||||
const shortcutLink = `${domain}/s/${shortcut.name}`;
|
|
||||||
chrome.tabs.create({ url: shortcutLink });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
"group w-auto px-3 py-2 flex flex-col justify-start items-start border rounded-lg hover:bg-gray-100 hover:shadow dark:border-zinc-800 dark:hover:bg-zinc-800"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="w-full flex flex-row justify-start items-center">
|
|
||||||
<span className={classNames("w-5 h-5 flex justify-center items-center overflow-clip shrink-0")}>
|
|
||||||
{favicon ? (
|
|
||||||
<img className="w-full h-auto rounded" src={favicon} decoding="async" loading="lazy" />
|
|
||||||
) : (
|
|
||||||
<Icon.CircleSlash className="w-full h-auto text-gray-400" />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<div className="ml-1 w-[calc(100%-20px)] flex flex-col justify-start items-start">
|
|
||||||
<div className="w-full flex flex-row justify-start items-center">
|
|
||||||
<button
|
|
||||||
className={classNames(
|
|
||||||
"max-w-full flex flex-row px-1 mr-1 justify-start items-center cursor-pointer rounded-md hover:underline"
|
|
||||||
)}
|
|
||||||
onClick={handleShortcutLinkClick}
|
|
||||||
>
|
|
||||||
<div className="truncate">
|
|
||||||
<span className="dark:text-gray-400">{shortcut.title}</span>
|
|
||||||
{shortcut.title ? (
|
|
||||||
<span className="text-gray-500">({shortcut.name})</span>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span className="truncate dark:text-gray-400">{shortcut.name}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="ml-1 cursor-pointer shrink-0 opacity-80">
|
|
||||||
<Icon.ExternalLink className="w-4 h-auto text-gray-600" />
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ShortcutView;
|
|
@ -1,26 +0,0 @@
|
|||||||
import classNames from "classnames";
|
|
||||||
import useShortcutStore from "@/store/shortcut";
|
|
||||||
import Icon from "./Icon";
|
|
||||||
import ShortcutView from "./ShortcutView";
|
|
||||||
|
|
||||||
const ShortcutsContainer = () => {
|
|
||||||
const shortcuts = useShortcutStore().getShortcutList();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="w-full flex flex-row justify-start items-center mb-4">
|
|
||||||
<a className="bg-blue-100 dark:bg-blue-500 dark:opacity-70 py-2 px-3 rounded-full border dark:border-blue-600 flex flex-row justify-start items-center cursor-pointer shadow">
|
|
||||||
<Icon.AlertCircle className="w-4 h-auto" />
|
|
||||||
<span className="mx-1 text-sm">Please make sure you have signed in your instance.</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className={classNames("w-full flex flex-row justify-start items-start flex-wrap gap-2")}>
|
|
||||||
{shortcuts.map((shortcut) => {
|
|
||||||
return <ShortcutView key={shortcut.id} shortcut={shortcut} />;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ShortcutsContainer;
|
|
18
frontend/extension/src/context/context.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
interface Context {
|
||||||
|
instanceUrl?: string;
|
||||||
|
setInstanceUrl: (instanceUrl: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StorageContext = createContext<Context>({
|
||||||
|
instanceUrl: undefined,
|
||||||
|
setInstanceUrl: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const useStorageContext = () => {
|
||||||
|
const context = useContext(StorageContext);
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useStorageContext;
|
4
frontend/extension/src/context/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import useStorageContext from "./context";
|
||||||
|
import StorageContextProvider from "./provider";
|
||||||
|
|
||||||
|
export { useStorageContext, StorageContextProvider };
|
41
frontend/extension/src/context/provider.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { Storage } from "@plasmohq/storage";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { StorageContext } from "./context";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StorageContextProvider = ({ children }: Props) => {
|
||||||
|
const storage = new Storage();
|
||||||
|
const [instanceUrl, setInstanceUrl] = useState<string | undefined>(undefined);
|
||||||
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const instanceUrl = await storage.get("instance_url");
|
||||||
|
|
||||||
|
setInstanceUrl(instanceUrl);
|
||||||
|
setIsInitialized(true);
|
||||||
|
})();
|
||||||
|
|
||||||
|
storage.watch({
|
||||||
|
instance_url: (c) => {
|
||||||
|
setInstanceUrl(c.newValue);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StorageContext.Provider
|
||||||
|
value={{
|
||||||
|
instanceUrl,
|
||||||
|
setInstanceUrl: (instanceUrl: string) => storage.set("instance_url", instanceUrl),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isInitialized && children}
|
||||||
|
</StorageContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StorageContextProvider;
|
@ -1,14 +0,0 @@
|
|||||||
import { isNull, isUndefined } from "lodash-es";
|
|
||||||
|
|
||||||
export const isNullorUndefined = (value: any) => {
|
|
||||||
return isNull(value) || isUndefined(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getFaviconWithGoogleS2 = (url: string) => {
|
|
||||||
try {
|
|
||||||
const urlObject = new URL(url);
|
|
||||||
return `https://www.google.com/s2/favicons?sz=128&domain=${urlObject.hostname}`;
|
|
||||||
} catch (error) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
@ -1,43 +0,0 @@
|
|||||||
import { useColorScheme } from "@mui/joy";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
const useColorTheme = () => {
|
|
||||||
const { mode: colorTheme, setMode: setColorTheme } = useColorScheme();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const root = document.documentElement;
|
|
||||||
if (colorTheme === "light") {
|
|
||||||
root.classList.remove("dark");
|
|
||||||
} else if (colorTheme === "dark") {
|
|
||||||
root.classList.add("dark");
|
|
||||||
} else {
|
|
||||||
const darkMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
|
||||||
if (darkMediaQuery.matches) {
|
|
||||||
root.classList.add("dark");
|
|
||||||
} else {
|
|
||||||
root.classList.remove("dark");
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleColorSchemeChange = (e: MediaQueryListEvent) => {
|
|
||||||
if (e.matches) {
|
|
||||||
root.classList.add("dark");
|
|
||||||
} else {
|
|
||||||
root.classList.remove("dark");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
darkMediaQuery.addEventListener("change", handleColorSchemeChange);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("failed to initial color scheme listener", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
darkMediaQuery.removeEventListener("change", handleColorSchemeChange);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [colorTheme]);
|
|
||||||
|
|
||||||
return { colorTheme, setColorTheme };
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useColorTheme;
|
|
@ -1,53 +1,26 @@
|
|||||||
import { Button, CssVarsProvider, Divider, Input, Select, Option } from "@mui/joy";
|
import { Button, CssVarsProvider, Input } from "@mui/joy";
|
||||||
import { useStorage } from "@plasmohq/storage/hook";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Toaster, toast } from "react-hot-toast";
|
import { Toaster, toast } from "react-hot-toast";
|
||||||
import Icon from "./components/Icon";
|
import Icon from "./components/Icon";
|
||||||
import Logo from "./components/Logo";
|
import Logo from "./components/Logo";
|
||||||
import PullShortcutsButton from "./components/PullShortcutsButton";
|
import { StorageContextProvider, useStorageContext } from "./context";
|
||||||
import ShortcutsContainer from "./components/ShortcutsContainer";
|
|
||||||
import useColorTheme from "./hooks/useColorTheme";
|
|
||||||
import useShortcutStore from "./store/shortcut";
|
|
||||||
import "./style.css";
|
import "./style.css";
|
||||||
|
|
||||||
interface SettingState {
|
interface SettingState {
|
||||||
domain: string;
|
instanceUrl: string;
|
||||||
accessToken: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const colorThemeOptions = [
|
|
||||||
{
|
|
||||||
value: "system",
|
|
||||||
label: "System",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "light",
|
|
||||||
label: "Light",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "dark",
|
|
||||||
label: "Dark",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const IndexOptions = () => {
|
const IndexOptions = () => {
|
||||||
const { colorTheme, setColorTheme } = useColorTheme();
|
const context = useStorageContext();
|
||||||
const [domain, setDomain] = useStorage<string>("domain", (v) => (v ? v : ""));
|
|
||||||
const [accessToken, setAccessToken] = useStorage<string>("access_token", (v) => (v ? v : ""));
|
|
||||||
const [settingState, setSettingState] = useState<SettingState>({
|
const [settingState, setSettingState] = useState<SettingState>({
|
||||||
domain,
|
instanceUrl: context.instanceUrl || "",
|
||||||
accessToken,
|
|
||||||
});
|
});
|
||||||
const shortcutStore = useShortcutStore();
|
|
||||||
const shortcuts = shortcutStore.getShortcutList();
|
|
||||||
const isInitialized = domain && accessToken;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSettingState({
|
setSettingState({
|
||||||
domain,
|
instanceUrl: context.instanceUrl || "",
|
||||||
accessToken,
|
|
||||||
});
|
});
|
||||||
}, [domain, accessToken]);
|
}, [context]);
|
||||||
|
|
||||||
const setPartialSettingState = (partialSettingState: Partial<SettingState>) => {
|
const setPartialSettingState = (partialSettingState: Partial<SettingState>) => {
|
||||||
setSettingState((prevState) => ({
|
setSettingState((prevState) => ({
|
||||||
@ -57,15 +30,10 @@ const IndexOptions = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveSetting = () => {
|
const handleSaveSetting = () => {
|
||||||
setDomain(settingState.domain);
|
context.setInstanceUrl(settingState.instanceUrl);
|
||||||
setAccessToken(settingState.accessToken);
|
|
||||||
toast.success("Setting saved");
|
toast.success("Setting saved");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectColorTheme = async (colorTheme: string) => {
|
|
||||||
setColorTheme(colorTheme as any);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full px-4">
|
<div className="w-full px-4">
|
||||||
<div className="w-full flex flex-row justify-center items-center">
|
<div className="w-full flex flex-row justify-center items-center">
|
||||||
@ -92,10 +60,10 @@ const IndexOptions = () => {
|
|||||||
<div className="w-full flex flex-col justify-start items-start mb-4">
|
<div className="w-full flex flex-col justify-start items-start mb-4">
|
||||||
<div className="mb-2 text-base w-full flex flex-row justify-between items-center">
|
<div className="mb-2 text-base w-full flex flex-row justify-between items-center">
|
||||||
<span className="dark:text-gray-400">Instance URL</span>
|
<span className="dark:text-gray-400">Instance URL</span>
|
||||||
{domain !== "" && (
|
{context.instanceUrl !== "" && (
|
||||||
<a
|
<a
|
||||||
className="text-sm flex flex-row justify-start items-center underline text-blue-600 hover:opacity-80"
|
className="text-sm flex flex-row justify-start items-center underline text-blue-600 hover:opacity-80"
|
||||||
href={domain}
|
href={context.instanceUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<span className="mr-1">Go to my Slash</span>
|
<span className="mr-1">Go to my Slash</span>
|
||||||
@ -108,21 +76,8 @@ const IndexOptions = () => {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="The url of your Slash instance. e.g., https://slash.example.com"
|
placeholder="The url of your Slash instance. e.g., https://slash.example.com"
|
||||||
value={settingState.domain}
|
value={settingState.instanceUrl}
|
||||||
onChange={(e) => setPartialSettingState({ domain: e.target.value })}
|
onChange={(e) => setPartialSettingState({ instanceUrl: e.target.value })}
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full flex flex-col justify-start items-start">
|
|
||||||
<span className="mb-2 text-base dark:text-gray-400">Access Token</span>
|
|
||||||
<div className="relative w-full">
|
|
||||||
<Input
|
|
||||||
className="w-full"
|
|
||||||
type="text"
|
|
||||||
placeholder="An available access token of your account."
|
|
||||||
value={settingState.accessToken}
|
|
||||||
onChange={(e) => setPartialSettingState({ accessToken: e.target.value })}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -130,39 +85,7 @@ const IndexOptions = () => {
|
|||||||
<div className="w-full mt-6 flex flex-row justify-end">
|
<div className="w-full mt-6 flex flex-row justify-end">
|
||||||
<Button onClick={handleSaveSetting}>Save</Button>
|
<Button onClick={handleSaveSetting}>Save</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Divider className="!my-6" />
|
|
||||||
|
|
||||||
<p className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-500">Preference</p>
|
|
||||||
|
|
||||||
<div className="w-full flex flex-row justify-between items-center">
|
|
||||||
<div className="flex flex-row justify-start items-center gap-x-1">
|
|
||||||
<span className="dark:text-gray-400">Color Theme</span>
|
|
||||||
</div>
|
</div>
|
||||||
<Select defaultValue={colorTheme} onChange={(_, value) => handleSelectColorTheme(value)}>
|
|
||||||
{colorThemeOptions.map((option) => {
|
|
||||||
return (
|
|
||||||
<Option key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</Option>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isInitialized && (
|
|
||||||
<>
|
|
||||||
<Divider className="!my-6" />
|
|
||||||
|
|
||||||
<h2 className="flex flex-row justify-start items-center mb-4">
|
|
||||||
<span className="text-lg dark:text-gray-400">Shortcuts</span>
|
|
||||||
<span className="text-gray-500 mr-1">({shortcuts.length})</span>
|
|
||||||
<PullShortcutsButton />
|
|
||||||
</h2>
|
|
||||||
<ShortcutsContainer />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -170,10 +93,12 @@ const IndexOptions = () => {
|
|||||||
|
|
||||||
const Options = () => {
|
const Options = () => {
|
||||||
return (
|
return (
|
||||||
|
<StorageContextProvider>
|
||||||
<CssVarsProvider>
|
<CssVarsProvider>
|
||||||
<IndexOptions />
|
<IndexOptions />
|
||||||
<Toaster position="top-center" />
|
<Toaster position="top-center" />
|
||||||
</CssVarsProvider>
|
</CssVarsProvider>
|
||||||
|
</StorageContextProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,31 +1,13 @@
|
|||||||
import { Button, CssVarsProvider, Divider, IconButton } from "@mui/joy";
|
import { Button, CssVarsProvider } from "@mui/joy";
|
||||||
import { useStorage } from "@plasmohq/storage/hook";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { Toaster } from "react-hot-toast";
|
import { Toaster } from "react-hot-toast";
|
||||||
import CreateShortcutButton from "@/components/CreateShortcutButton";
|
|
||||||
import Icon from "@/components/Icon";
|
import Icon from "@/components/Icon";
|
||||||
import Logo from "@/components/Logo";
|
import Logo from "@/components/Logo";
|
||||||
import PullShortcutsButton from "@/components/PullShortcutsButton";
|
import { StorageContextProvider, useStorageContext } from "./context";
|
||||||
import ShortcutsContainer from "@/components/ShortcutsContainer";
|
|
||||||
import useColorTheme from "./hooks/useColorTheme";
|
|
||||||
import useShortcutStore from "./store/shortcut";
|
|
||||||
import "./style.css";
|
import "./style.css";
|
||||||
|
|
||||||
const IndexPopup = () => {
|
const IndexPopup = () => {
|
||||||
useColorTheme();
|
const context = useStorageContext();
|
||||||
const [instanceUrl] = useStorage<string>("domain", "");
|
const isInitialized = context.instanceUrl;
|
||||||
const [accessToken] = useStorage<string>("access_token", "");
|
|
||||||
const shortcutStore = useShortcutStore();
|
|
||||||
const shortcuts = shortcutStore.getShortcutList();
|
|
||||||
const isInitialized = instanceUrl && accessToken;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isInitialized) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
shortcutStore.fetchShortcutList(instanceUrl, accessToken);
|
|
||||||
}, [isInitialized]);
|
|
||||||
|
|
||||||
const handleSettingButtonClick = () => {
|
const handleSettingButtonClick = () => {
|
||||||
chrome.runtime.openOptionsPage();
|
chrome.runtime.openOptionsPage();
|
||||||
@ -42,63 +24,47 @@ const IndexPopup = () => {
|
|||||||
<div className="flex flex-row justify-start items-center dark:text-gray-400">
|
<div className="flex flex-row justify-start items-center dark:text-gray-400">
|
||||||
<Logo className="w-6 h-auto mr-1" />
|
<Logo className="w-6 h-auto mr-1" />
|
||||||
<span className="">Slash</span>
|
<span className="">Slash</span>
|
||||||
{isInitialized && (
|
|
||||||
<>
|
|
||||||
<span className="mx-1 text-gray-400">/</span>
|
|
||||||
<span>Shortcuts</span>
|
|
||||||
<span className="text-gray-500 mr-0.5">({shortcuts.length})</span>
|
|
||||||
<PullShortcutsButton />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div>{isInitialized && <CreateShortcutButton />}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full mt-4">
|
<div className="w-full mt-4">
|
||||||
{isInitialized ? (
|
{isInitialized ? (
|
||||||
<>
|
<>
|
||||||
{shortcuts.length !== 0 ? (
|
<p className="w-full mb-2">
|
||||||
<ShortcutsContainer />
|
<span>Your instance URL is </span>
|
||||||
) : (
|
<a
|
||||||
<div className="w-full flex flex-col justify-center items-center">
|
className="inline-flex flex-row justify-start items-center underline text-blue-600 hover:opacity-80"
|
||||||
<p>No shortcut found.</p>
|
href={context.instanceUrl}
|
||||||
</div>
|
target="_blank"
|
||||||
)}
|
>
|
||||||
|
<span className="mr-1">{context.instanceUrl}</span>
|
||||||
<Divider className="!mt-4 !mb-2 opacity-40" />
|
<Icon.ExternalLink className="w-4 h-auto" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
<div className="w-full flex flex-row justify-between items-center mb-2">
|
<div className="w-full flex flex-row justify-between items-center mb-2">
|
||||||
<div className="flex flex-row justify-start items-center">
|
<div className="flex flex-row justify-start items-center gap-2">
|
||||||
<IconButton size="sm" variant="plain" color="neutral" onClick={handleSettingButtonClick}>
|
<Button size="sm" variant="outlined" color="neutral" onClick={handleSettingButtonClick}>
|
||||||
<Icon.Settings className="w-5 h-auto text-gray-500 dark:text-gray-400" />
|
<Icon.Settings className="w-5 h-auto text-gray-500 dark:text-gray-400 mr-1" />
|
||||||
</IconButton>
|
Setting
|
||||||
<IconButton
|
</Button>
|
||||||
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="plain"
|
variant="outlined"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
component="a"
|
component="a"
|
||||||
href="https://github.com/yourselfhosted/slash"
|
href="https://github.com/yourselfhosted/slash"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<Icon.Github className="w-5 h-auto text-gray-500 dark:text-gray-400" />
|
<Icon.Github className="w-5 h-auto text-gray-500 dark:text-gray-400 mr-1" />
|
||||||
</IconButton>
|
GitHub
|
||||||
</div>
|
</Button>
|
||||||
<div className="flex flex-row justify-end items-center">
|
|
||||||
<a
|
|
||||||
className="text-sm flex flex-row justify-start items-center underline text-blue-600 hover:opacity-80"
|
|
||||||
href={instanceUrl}
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<span className="mr-1">Go to my Slash</span>
|
|
||||||
<Icon.ExternalLink className="w-4 h-auto" />
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full flex flex-col justify-start items-center">
|
<div className="w-full flex flex-col justify-start items-center">
|
||||||
<Icon.Cookie strokeWidth={1} className="w-20 h-auto mb-4 text-gray-400" />
|
<Icon.Cookie strokeWidth={1} className="w-20 h-auto mb-4 text-gray-400" />
|
||||||
<p className="dark:text-gray-400">Please set your instance URL and access token first.</p>
|
<p className="dark:text-gray-400">Please set your instance URL first.</p>
|
||||||
<div className="w-full flex flex-row justify-center items-center py-4">
|
<div className="w-full flex flex-row justify-center items-center py-4">
|
||||||
<Button size="sm" color="primary" onClick={handleSettingButtonClick}>
|
<Button size="sm" color="primary" onClick={handleSettingButtonClick}>
|
||||||
<Icon.Settings className="w-5 h-auto mr-1" /> Go to Setting
|
<Icon.Settings className="w-5 h-auto mr-1" /> Go to Setting
|
||||||
@ -117,10 +83,12 @@ const IndexPopup = () => {
|
|||||||
|
|
||||||
const Popup = () => {
|
const Popup = () => {
|
||||||
return (
|
return (
|
||||||
|
<StorageContextProvider>
|
||||||
<CssVarsProvider>
|
<CssVarsProvider>
|
||||||
<IndexPopup />
|
<IndexPopup />
|
||||||
<Toaster position="top-center" />
|
<Toaster position="top-center" />
|
||||||
</CssVarsProvider>
|
</CssVarsProvider>
|
||||||
|
</StorageContextProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,55 +0,0 @@
|
|||||||
import axios from "axios";
|
|
||||||
import { create } from "zustand";
|
|
||||||
import { combine } from "zustand/middleware";
|
|
||||||
import { CreateShortcutResponse, ListShortcutsResponse, Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
shortcutMapById: Record<number, Shortcut>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getDefaultState = (): State => {
|
|
||||||
return {
|
|
||||||
shortcutMapById: {},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const useShortcutStore = create(
|
|
||||||
combine(getDefaultState(), (set, get) => ({
|
|
||||||
fetchShortcutList: async (instanceUrl: string, accessToken: string) => {
|
|
||||||
const {
|
|
||||||
data: { shortcuts },
|
|
||||||
} = await axios.get<ListShortcutsResponse>(`${instanceUrl}/api/v2/shortcuts`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const shortcutMap = get().shortcutMapById;
|
|
||||||
shortcuts.forEach((shortcut) => {
|
|
||||||
shortcutMap[shortcut.id] = shortcut;
|
|
||||||
});
|
|
||||||
set({ shortcutMapById: shortcutMap });
|
|
||||||
return shortcuts;
|
|
||||||
},
|
|
||||||
getShortcutList: () => {
|
|
||||||
return Object.values(get().shortcutMapById);
|
|
||||||
},
|
|
||||||
createShortcut: async (instanceUrl: string, accessToken: string, create: Shortcut) => {
|
|
||||||
const {
|
|
||||||
data: { shortcut },
|
|
||||||
} = await axios.post<CreateShortcutResponse>(`${instanceUrl}/api/v2/shortcuts`, create, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!shortcut) {
|
|
||||||
throw new Error(`Failed to create shortcut`);
|
|
||||||
}
|
|
||||||
const shortcutMap = get().shortcutMapById;
|
|
||||||
shortcutMap[shortcut.id] = shortcut;
|
|
||||||
set({ shortcutMapById: shortcutMap });
|
|
||||||
return shortcut;
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
export default useShortcutStore;
|
|
@ -1,19 +1,10 @@
|
|||||||
{
|
{
|
||||||
"extends": "plasmo/templates/tsconfig.base",
|
"extends": "plasmo/templates/tsconfig.base",
|
||||||
"exclude": [
|
"exclude": ["node_modules"],
|
||||||
"node_modules"
|
"include": [".plasmo/index.d.ts", "./**/*.ts", "./**/*.tsx"],
|
||||||
],
|
|
||||||
"include": [
|
|
||||||
".plasmo/index.d.ts",
|
|
||||||
"./**/*.ts",
|
|
||||||
"./**/*.tsx",
|
|
||||||
"../types"
|
|
||||||
],
|
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": [
|
"@/*": ["./src/*"]
|
||||||
"./src/*"
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
"baseUrl": "."
|
"baseUrl": "."
|
||||||
}
|
}
|
||||||
|
@ -12,13 +12,16 @@
|
|||||||
"search": "Search",
|
"search": "Search",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"account": "Account"
|
"account": "Account",
|
||||||
|
"or": "Or"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"sign-in": "Sign in",
|
"sign-in": "Sign in",
|
||||||
"sign-up": "Sign up",
|
"sign-up": "Sign up",
|
||||||
"sign-out": "Sign out",
|
"sign-out": "Sign out",
|
||||||
"create-your-account": "Create your account"
|
"create-your-account": "Create your account",
|
||||||
|
"host-tip": "You are registering as Admin.",
|
||||||
|
"sign-in-with": "Sign in with {{provider}}"
|
||||||
},
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
"self": "Analytics",
|
"self": "Analytics",
|
||||||
@ -33,23 +36,19 @@
|
|||||||
"shortcut": {
|
"shortcut": {
|
||||||
"visits": "{{count}} visits",
|
"visits": "{{count}} visits",
|
||||||
"visibility": {
|
"visibility": {
|
||||||
"private": {
|
|
||||||
"self": "Private",
|
|
||||||
"description": "Only you can access"
|
|
||||||
},
|
|
||||||
"workspace": {
|
"workspace": {
|
||||||
"self": "Workspace",
|
"self": "Workspace",
|
||||||
"description": "Workspace members can access"
|
"description": "Workspace members can access"
|
||||||
},
|
},
|
||||||
"public": {
|
"public": {
|
||||||
"self": "Public",
|
"self": "Public",
|
||||||
"description": "Visible to everyone on the internet"
|
"description": "Public on the internet"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"mine": "Mine",
|
"personal": "Personal",
|
||||||
"compact-mode": "Compact mode",
|
"compact-mode": "Compact mode",
|
||||||
"order-by": "Order by",
|
"order-by": "Order by",
|
||||||
"direction": "Direction"
|
"direction": "Direction"
|
||||||
@ -59,10 +58,7 @@
|
|||||||
"nickname": "Nickname",
|
"nickname": "Nickname",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"role": "Role",
|
"role": "Role",
|
||||||
"profile": "Profile",
|
"profile": "Profile"
|
||||||
"action": {
|
|
||||||
"add-user": "Add user"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"self": "Setting",
|
"self": "Setting",
|
||||||
@ -73,9 +69,13 @@
|
|||||||
"workspace": {
|
"workspace": {
|
||||||
"self": "Workspace settings",
|
"self": "Workspace settings",
|
||||||
"custom-style": "Custom style",
|
"custom-style": "Custom style",
|
||||||
"enable-user-signup": {
|
"disallow-user-registration": {
|
||||||
"self": "Enable user signup",
|
"self": "Disallow user registration"
|
||||||
"description": "Once enabled, other users can signup."
|
},
|
||||||
|
"default-visibility": "Default visibility",
|
||||||
|
"member": {
|
||||||
|
"self": "Member",
|
||||||
|
"add": "Add member"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
79
frontend/locales/fr.json
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"about": "À propos",
|
||||||
|
"loading": "Chargement",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"save": "Sauver",
|
||||||
|
"create": "Créer",
|
||||||
|
"download": "Télécharger",
|
||||||
|
"edit": "Modifier",
|
||||||
|
"delete": "Supprimer",
|
||||||
|
"language": "Langue",
|
||||||
|
"search": "Recherche",
|
||||||
|
"email": "E-mail",
|
||||||
|
"password": "Mot de passe",
|
||||||
|
"account": "Compte"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"sign-in": "Se connecter",
|
||||||
|
"sign-up": "S'inscrire",
|
||||||
|
"sign-out": "Se déconnecter",
|
||||||
|
"create-your-account": "Créez votre compte"
|
||||||
|
},
|
||||||
|
"analytics": {
|
||||||
|
"self": "Analyse",
|
||||||
|
"top-sources": "Principales sources",
|
||||||
|
"source": "Source",
|
||||||
|
"visitors": "Visiteurs",
|
||||||
|
"devices": "Dispositifs",
|
||||||
|
"browser": "Navigateur",
|
||||||
|
"browsers": "Navigateurs",
|
||||||
|
"operating-system": "Systèmes d'exploitation"
|
||||||
|
},
|
||||||
|
"shortcut": {
|
||||||
|
"visits": "{{count}} visites",
|
||||||
|
"visibility": {
|
||||||
|
"workspace": {
|
||||||
|
"self": "Espace de travail",
|
||||||
|
"description": "Les membres de l'espace de travail ont accès"
|
||||||
|
},
|
||||||
|
"public": {
|
||||||
|
"self": "Public",
|
||||||
|
"description": "Visible par tous sur Internet"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"all": "Tout",
|
||||||
|
"mine": "Le mien",
|
||||||
|
"compact-mode": "Mode compact",
|
||||||
|
"order-by": "Commandé par",
|
||||||
|
"direction": "Direction"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"self": "Utilisateur",
|
||||||
|
"nickname": "Surnom",
|
||||||
|
"email": "E-mail",
|
||||||
|
"role": "Rôle",
|
||||||
|
"profile": "Profil",
|
||||||
|
"action": {
|
||||||
|
"add-user": "Ajouter utilisateur"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"self": "Paramètres",
|
||||||
|
"preference": {
|
||||||
|
"self": "Préférence",
|
||||||
|
"color-theme": "Thème de couleur"
|
||||||
|
},
|
||||||
|
"workspace": {
|
||||||
|
"self": "Paramètres de l'espace de travail",
|
||||||
|
"custom-style": "Style personnalisé",
|
||||||
|
"enable-user-signup": {
|
||||||
|
"self": "Activer l'inscription des utilisateurs",
|
||||||
|
"description": "Une fois activé, d'autres utilisateurs peuvent s'inscrire."
|
||||||
|
},
|
||||||
|
"default-visibility": "Visibilité par défaut"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
80
frontend/locales/hu.json
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"about": "Névjegy",
|
||||||
|
"loading": "Betöltés",
|
||||||
|
"cancel": "Mégse",
|
||||||
|
"save": "Mentés",
|
||||||
|
"create": "Létrehozás",
|
||||||
|
"download": "Letöltés",
|
||||||
|
"edit": "Szerkesztés",
|
||||||
|
"delete": "Törlés",
|
||||||
|
"language": "Nyelv",
|
||||||
|
"search": "Keresés",
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Jelszó",
|
||||||
|
"account": "Fiók"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"sign-in": "Bejelentkezés",
|
||||||
|
"sign-up": "Regisztráció",
|
||||||
|
"sign-out": "Kijelentkezés",
|
||||||
|
"create-your-account": "Fiók létrehozás",
|
||||||
|
"host-tip": "Adminisztrátorként regisztrál."
|
||||||
|
},
|
||||||
|
"analytics": {
|
||||||
|
"self": "Analitika",
|
||||||
|
"top-sources": "Legfontosabb források",
|
||||||
|
"source": "Forrás",
|
||||||
|
"visitors": "Látogatók",
|
||||||
|
"devices": "Eszközök",
|
||||||
|
"browser": "Böngésző",
|
||||||
|
"browsers": "Böngészők",
|
||||||
|
"operating-system": "Operációs rendszer"
|
||||||
|
},
|
||||||
|
"shortcut": {
|
||||||
|
"visits": "{{count}} látogatás",
|
||||||
|
"visibility": {
|
||||||
|
"workspace": {
|
||||||
|
"self": "Munkaterület",
|
||||||
|
"description": "A munkaterület tagjai hozzáférhetnek"
|
||||||
|
},
|
||||||
|
"public": {
|
||||||
|
"self": "Nyilvános",
|
||||||
|
"description": "Mindenki számára látható az interneten"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"all": "Összes",
|
||||||
|
"mine": "Saját",
|
||||||
|
"compact-mode": "Kompakt mód",
|
||||||
|
"order-by": "Rendezés",
|
||||||
|
"direction": "Irány"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"self": "Felhasználó",
|
||||||
|
"nickname": "Becenév",
|
||||||
|
"email": "Email",
|
||||||
|
"role": "Szerep",
|
||||||
|
"profile": "Profil",
|
||||||
|
"action": {
|
||||||
|
"add-user": "Felhasználó hozzáadása"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"self": "Beállítás",
|
||||||
|
"preference": {
|
||||||
|
"self": "Preferencia",
|
||||||
|
"color-theme": "Színtéma"
|
||||||
|
},
|
||||||
|
"workspace": {
|
||||||
|
"self": "Munkaterület beállítások",
|
||||||
|
"custom-style": "Egyéni stílus",
|
||||||
|
"enable-user-signup": {
|
||||||
|
"self": "Felhasználói regisztráció engedélyezése",
|
||||||
|
"description": "Ha engedélyezve van, más felhasználók is regisztrálhatnak."
|
||||||
|
},
|
||||||
|
"default-visibility": "Alapértelmezett láthatóság"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
80
frontend/locales/ja.json
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"about": "About",
|
||||||
|
"loading": "読込中",
|
||||||
|
"cancel": "取消",
|
||||||
|
"save": "保存",
|
||||||
|
"create": "作成",
|
||||||
|
"download": "ダウンロード",
|
||||||
|
"edit": "編集",
|
||||||
|
"delete": "削除",
|
||||||
|
"language": "言語",
|
||||||
|
"search": "検索",
|
||||||
|
"email": "Eメール",
|
||||||
|
"password": "パスワード",
|
||||||
|
"account": "アカウント"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"sign-in": "サインイン",
|
||||||
|
"sign-up": "登録",
|
||||||
|
"sign-out": "サインアウト",
|
||||||
|
"create-your-account": "アカウントを作成してください",
|
||||||
|
"host-tip": "管理者として登録されています。"
|
||||||
|
},
|
||||||
|
"analytics": {
|
||||||
|
"self": "分析",
|
||||||
|
"top-sources": "トップソース",
|
||||||
|
"source": "ソース",
|
||||||
|
"visitors": "訪問者",
|
||||||
|
"devices": "デバイス",
|
||||||
|
"browser": "ブラウザ",
|
||||||
|
"browsers": "ブラウザ",
|
||||||
|
"operating-system": "オペレーティングシステム"
|
||||||
|
},
|
||||||
|
"shortcut": {
|
||||||
|
"visits": "{{count}} 回訪問",
|
||||||
|
"visibility": {
|
||||||
|
"workspace": {
|
||||||
|
"self": "ワークスペース",
|
||||||
|
"description": "ワークスペースメンバーがアクセスできます"
|
||||||
|
},
|
||||||
|
"public": {
|
||||||
|
"self": "公開",
|
||||||
|
"description": "誰でもアクセスできます"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"all": "全て",
|
||||||
|
"mine": "自分",
|
||||||
|
"compact-mode": "コンパクトモード",
|
||||||
|
"order-by": "順序",
|
||||||
|
"direction": "方向"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"self": "ユーザー",
|
||||||
|
"nickname": "ニックネーム",
|
||||||
|
"email": "Eメール",
|
||||||
|
"role": "役割",
|
||||||
|
"profile": "プロフィール",
|
||||||
|
"action": {
|
||||||
|
"add-user": "ユーザーの追加"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"self": "設定",
|
||||||
|
"preference": {
|
||||||
|
"self": "プリファレンス",
|
||||||
|
"color-theme": "カラーテーマ"
|
||||||
|
},
|
||||||
|
"workspace": {
|
||||||
|
"self": "ワークスペースの設定",
|
||||||
|
"custom-style": "カスタムスタイル",
|
||||||
|
"enable-user-signup": {
|
||||||
|
"self": "ユーザーの登録を有効にする",
|
||||||
|
"description": "有効にすると他のユーザーが登録できるようになります。"
|
||||||
|
},
|
||||||
|
"default-visibility": "デフォルトの表示"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
80
frontend/locales/ru.json
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"about": "Информация",
|
||||||
|
"loading": "Загружается",
|
||||||
|
"cancel": "Отменить",
|
||||||
|
"save": "Сохранить",
|
||||||
|
"create": "Создать",
|
||||||
|
"download": "Загрузить",
|
||||||
|
"edit": "Редактировать",
|
||||||
|
"delete": "Удалить",
|
||||||
|
"language": "Язык",
|
||||||
|
"search": "Поиск",
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Пароль",
|
||||||
|
"account": "Аккаунт"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"sign-in": "Войти",
|
||||||
|
"sign-up": "Регистрация",
|
||||||
|
"sign-out": "Выйти",
|
||||||
|
"create-your-account": "Создать аккаунт",
|
||||||
|
"host-tip": "Вы зарегистрированы как Admin."
|
||||||
|
},
|
||||||
|
"analytics": {
|
||||||
|
"self": "Аналитика",
|
||||||
|
"top-sources": "Лучшие источники",
|
||||||
|
"source": "Источник",
|
||||||
|
"visitors": "Посетители",
|
||||||
|
"devices": "Устройства",
|
||||||
|
"browser": "Браузер",
|
||||||
|
"browsers": "Браузеры",
|
||||||
|
"operating-system": "Операционная система"
|
||||||
|
},
|
||||||
|
"shortcut": {
|
||||||
|
"visits": "{{count}} перехода",
|
||||||
|
"visibility": {
|
||||||
|
"workspace": {
|
||||||
|
"self": "Команда",
|
||||||
|
"description": "Члены команды имеют доступ"
|
||||||
|
},
|
||||||
|
"public": {
|
||||||
|
"self": "Публичная",
|
||||||
|
"description": "Видимая для всех из интернета"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"all": "Все",
|
||||||
|
"mine": "Мои",
|
||||||
|
"compact-mode": "Компактный режим",
|
||||||
|
"order-by": "Создана",
|
||||||
|
"direction": "Путь"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"self": "Пользователь",
|
||||||
|
"nickname": "Имя пользователя",
|
||||||
|
"email": "Email",
|
||||||
|
"role": "Роль",
|
||||||
|
"profile": "Профиль",
|
||||||
|
"action": {
|
||||||
|
"add-user": "Добавить пользователя"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"self": "Настройки",
|
||||||
|
"preference": {
|
||||||
|
"self": "Внешний вид",
|
||||||
|
"color-theme": "Цветовая схема"
|
||||||
|
},
|
||||||
|
"workspace": {
|
||||||
|
"self": "Настройки команды",
|
||||||
|
"custom-style": "Пользовательский стиль",
|
||||||
|
"enable-user-signup": {
|
||||||
|
"self": "Разрешить регистрацию пользователей",
|
||||||
|
"description": "После включения, другие пользователи смогут зарегистрироваться."
|
||||||
|
},
|
||||||
|
"default-visibility": "Отображение по умолчанию"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
79
frontend/locales/tr.json
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"about": "Hakkında",
|
||||||
|
"loading": "Yükleniyor",
|
||||||
|
"cancel": "İptal",
|
||||||
|
"save": "Kaydet",
|
||||||
|
"create": "Oluştur",
|
||||||
|
"download": "İndir",
|
||||||
|
"edit": "Düzenle",
|
||||||
|
"delete": "Sil",
|
||||||
|
"language": "Dil",
|
||||||
|
"search": "Ara",
|
||||||
|
"email": "E-posta",
|
||||||
|
"password": "Şifre",
|
||||||
|
"account": "Hesap"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"sign-in": "Giriş yap",
|
||||||
|
"sign-up": "Kaydol",
|
||||||
|
"sign-out": "Çıkış yap",
|
||||||
|
"create-your-account": "Hesabınızı oluşturun"
|
||||||
|
},
|
||||||
|
"analytics": {
|
||||||
|
"self": "Analizler",
|
||||||
|
"top-sources": "En İyi Kaynaklar",
|
||||||
|
"source": "Kaynak",
|
||||||
|
"visitors": "Ziyâretçiler",
|
||||||
|
"devices": "Cihazlar",
|
||||||
|
"browser": "Tarayıcı",
|
||||||
|
"browsers": "Tarayıcılar",
|
||||||
|
"operating-system": "İşletim Sistemi"
|
||||||
|
},
|
||||||
|
"shortcut": {
|
||||||
|
"visits": "{{count}} ziyaret",
|
||||||
|
"visibility": {
|
||||||
|
"workspace": {
|
||||||
|
"self": "Çalışma Alanı",
|
||||||
|
"description": "Çalışma alanı üyeleri erişebilir"
|
||||||
|
},
|
||||||
|
"public": {
|
||||||
|
"self": "Herkese açık",
|
||||||
|
"description": "İnternette herkese görünür"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"all": "Hepsi",
|
||||||
|
"mine": "Benim",
|
||||||
|
"compact-mode": "Kompakt mod",
|
||||||
|
"order-by": "Sırala",
|
||||||
|
"direction": "Yön"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"self": "Kullanıcı",
|
||||||
|
"nickname": "Takma ad",
|
||||||
|
"email": "E-posta",
|
||||||
|
"role": "Rol",
|
||||||
|
"profile": "Profil",
|
||||||
|
"action": {
|
||||||
|
"add-user": "Kullanıcı ekle"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"self": "Ayarlar",
|
||||||
|
"preference": {
|
||||||
|
"self": "Tercihler",
|
||||||
|
"color-theme": "Renk teması"
|
||||||
|
},
|
||||||
|
"workspace": {
|
||||||
|
"self": "Çalışma alanı ayarları",
|
||||||
|
"custom-style": "Özel stil",
|
||||||
|
"enable-user-signup": {
|
||||||
|
"self": "Kullanıcı kaydını etkinleştir",
|
||||||
|
"description": "Etkinleştirildiğinde, diğer kullanıcılar kaydolabilir."
|
||||||
|
},
|
||||||
|
"default-visibility": "Varsayılan görünürlük"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -33,23 +33,19 @@
|
|||||||
"shortcut": {
|
"shortcut": {
|
||||||
"visits": "{{count}} 次访问",
|
"visits": "{{count}} 次访问",
|
||||||
"visibility": {
|
"visibility": {
|
||||||
"private": {
|
|
||||||
"self": "私有的",
|
|
||||||
"description": "仅您可以访问"
|
|
||||||
},
|
|
||||||
"workspace": {
|
"workspace": {
|
||||||
"self": "工作区",
|
"self": "工作区",
|
||||||
"description": "工作区成员可以访问"
|
"description": "工作区成员可以访问"
|
||||||
},
|
},
|
||||||
"public": {
|
"public": {
|
||||||
"self": "公开的",
|
"self": "公开的",
|
||||||
"description": "对任何人可见"
|
"description": "公开至互联网"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"all": "所有",
|
"all": "所有",
|
||||||
"mine": "我的",
|
"personal": "我的",
|
||||||
"compact-mode": "紧凑模式",
|
"compact-mode": "紧凑模式",
|
||||||
"order-by": "排序方式",
|
"order-by": "排序方式",
|
||||||
"direction": "方向"
|
"direction": "方向"
|
||||||
@ -76,7 +72,8 @@
|
|||||||
"enable-user-signup": {
|
"enable-user-signup": {
|
||||||
"self": "启用用户注册",
|
"self": "启用用户注册",
|
||||||
"description": "允许其他用户注册新账号"
|
"description": "允许其他用户注册新账号"
|
||||||
}
|
},
|
||||||
|
"default-visibility": "默认可见性"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
6
frontend/web/.vscode/setting.json
vendored
@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
|
|
||||||
"editor.codeActionsOnSave": {
|
|
||||||
"source.fixAll.eslint": true
|
|
||||||
}
|
|
||||||
}
|
|
@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" href="/logo.png" type="image/*" />
|
<link rel="icon" href="/logo.svg" type="image/*" />
|
||||||
<meta name="theme-color" content="#FFFFFF" />
|
<meta name="theme-color" content="#FFFFFF" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
|
||||||
<!-- slash.metadata -->
|
<!-- slash.metadata -->
|
||||||
|
@ -2,54 +2,58 @@
|
|||||||
"name": "slash",
|
"name": "slash",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "vite build",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
"lint": "eslint --ext .js,.ts,.tsx, src",
|
"lint": "eslint --ext .js,.ts,.tsx, src",
|
||||||
"lint-fix": "eslint --ext .js,.ts,.tsx, src --fix",
|
"lint-fix": "eslint --ext .js,.ts,.tsx, src --fix",
|
||||||
"type-gen": "cd ../../proto && buf generate"
|
"type-check": "tsc --noEmit --skipLibCheck",
|
||||||
|
"postinstall": "cd ../../proto && buf generate"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.11.3",
|
"@emotion/react": "^11.13.3",
|
||||||
"@emotion/styled": "^11.11.0",
|
"@emotion/styled": "^11.13.0",
|
||||||
"@mui/joy": "5.0.0-beta.23",
|
"@mui/joy": "5.0.0-beta.48",
|
||||||
"@reduxjs/toolkit": "^1.9.7",
|
"@reduxjs/toolkit": "^2.3.0",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"copy-to-clipboard": "^3.3.3",
|
"copy-to-clipboard": "^3.3.3",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.13",
|
||||||
"i18next": "^23.7.18",
|
"i18next": "^23.16.4",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"lucide-react": "^0.312.0",
|
"lucide-react": "^0.446.0",
|
||||||
"nice-grpc-web": "^3.3.2",
|
"nice-grpc-web": "^3.3.5",
|
||||||
"qrcode.react": "^3.1.0",
|
"qrcode.react": "^4.1.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.3.1",
|
||||||
"react-hot-toast": "^2.4.1",
|
"react-hot-toast": "^2.4.1",
|
||||||
"react-i18next": "^13.5.0",
|
"react-i18next": "^15.1.0",
|
||||||
"react-router-dom": "^6.21.3",
|
"react-router-dom": "^6.27.0",
|
||||||
"react-use": "^17.4.3",
|
"react-use": "^17.5.1",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.14",
|
||||||
"zustand": "^4.5.0"
|
"uuid": "^10.0.0",
|
||||||
|
"zustand": "^5.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@bufbuild/buf": "^1.28.1",
|
"@bufbuild/buf": "^1.46.0",
|
||||||
|
"@bufbuild/protobuf": "^2.2.2",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/react": "^18.2.48",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.2.18",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"@typescript-eslint/parser": "^6.19.0",
|
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
"@typescript-eslint/parser": "^7.18.0",
|
||||||
"autoprefixer": "^10.4.17",
|
"@vitejs/plugin-react-swc": "^3.7.1",
|
||||||
"eslint": "^8.56.0",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint-config-prettier": "^8.10.0",
|
"eslint": "^8.57.1",
|
||||||
"eslint-plugin-prettier": "^4.2.1",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-react": "^7.33.2",
|
"eslint-plugin-prettier": "^5.2.1",
|
||||||
|
"eslint-plugin-react": "^7.37.2",
|
||||||
"long": "^5.2.3",
|
"long": "^5.2.3",
|
||||||
"postcss": "^8.4.33",
|
"postcss": "^8.4.47",
|
||||||
"prettier": "2.6.2",
|
"prettier": "^3.3.3",
|
||||||
"protobufjs": "^7.2.6",
|
"protobufjs": "^7.4.0",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.6.3",
|
||||||
"vite": "^5.0.12"
|
"vite": "^5.4.10"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"csstype": "3.1.2"
|
"csstype": "3.1.2"
|
||||||
|
6119
frontend/web/pnpm-lock.yaml
generated
Before Width: | Height: | Size: 19 KiB |
1
frontend/web/public/logo.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-slash"><line x1="9" x2="15" y1="15" y2="9"/><circle cx="12" cy="12" r="10"/></svg>
|
After Width: | Height: | Size: 291 B |
@ -1,26 +1,24 @@
|
|||||||
import { useColorScheme } from "@mui/joy";
|
import { useColorScheme } from "@mui/joy";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect } from "react";
|
||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
import DemoBanner from "./components/DemoBanner";
|
import DemoBanner from "@/components/DemoBanner";
|
||||||
import useUserStore from "./stores/v1/user";
|
import { useWorkspaceStore } from "@/stores";
|
||||||
import useWorkspaceStore from "./stores/v1/workspace";
|
import useNavigateTo from "./hooks/useNavigateTo";
|
||||||
|
import { FeatureType } from "./stores/workspace";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const navigateTo = useNavigateTo();
|
||||||
const { mode: colorScheme } = useColorScheme();
|
const { mode: colorScheme } = useColorScheme();
|
||||||
const userStore = useUserStore();
|
|
||||||
const workspaceStore = useWorkspaceStore();
|
const workspaceStore = useWorkspaceStore();
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
|
// Redirect to sign up page if no instance owner.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
if (!workspaceStore.profile.owner) {
|
||||||
try {
|
navigateTo("/auth/signup", {
|
||||||
await Promise.all([workspaceStore.fetchWorkspaceProfile(), workspaceStore.fetchWorkspaceSetting(), userStore.fetchCurrentUser()]);
|
replace: true,
|
||||||
} catch (error) {
|
});
|
||||||
// Do nothing.
|
|
||||||
}
|
}
|
||||||
setLoading(false);
|
}, [workspaceStore.profile]);
|
||||||
})();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const styleEl = document.createElement("style");
|
const styleEl = document.createElement("style");
|
||||||
@ -29,6 +27,16 @@ function App() {
|
|||||||
document.body.insertAdjacentElement("beforeend", styleEl);
|
document.body.insertAdjacentElement("beforeend", styleEl);
|
||||||
}, [workspaceStore.setting.customStyle]);
|
}, [workspaceStore.setting.customStyle]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hasCustomBranding = workspaceStore.checkFeatureAvailable(FeatureType.CustomeBranding);
|
||||||
|
if (!hasCustomBranding || !workspaceStore.setting.branding) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const favicon = document.querySelector("link[rel='icon']") as HTMLLinkElement;
|
||||||
|
favicon.href = new TextDecoder().decode(workspaceStore.setting.branding);
|
||||||
|
}, [workspaceStore.setting.branding]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
if (colorScheme === "light") {
|
if (colorScheme === "light") {
|
||||||
@ -62,13 +70,11 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [colorScheme]);
|
}, [colorScheme]);
|
||||||
|
|
||||||
return !loading ? (
|
return (
|
||||||
<>
|
<>
|
||||||
<DemoBanner />
|
<DemoBanner />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</>
|
</>
|
||||||
) : (
|
|
||||||
<></>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -21,10 +21,11 @@ const AboutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="max-w-full w-80 sm:w-96">
|
<div className="max-w-full w-80 sm:w-96">
|
||||||
<p>
|
<p>
|
||||||
<span className="font-medium">Slash</span>: An open source, self-hosted bookmarks and link sharing platform.
|
<span className="font-medium">Slash</span> is an open source, self-hosted platform for sharing and managing your most frequently
|
||||||
|
used links.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<span className="mr-2">See more in</span>
|
<span className="mr-2">Source code:</span>
|
||||||
<Link variant="plain" href="https://github.com/yourselfhosted/slash" target="_blank">
|
<Link variant="plain" href="https://github.com/yourselfhosted/slash" target="_blank">
|
||||||
GitHub
|
GitHub
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -2,7 +2,7 @@ import classNames from "classnames";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { shortcutServiceClient } from "@/grpcweb";
|
import { shortcutServiceClient } from "@/grpcweb";
|
||||||
import { GetShortcutAnalyticsResponse } from "@/types/proto/api/v2/shortcut_service";
|
import { GetShortcutAnalyticsResponse } from "@/types/proto/api/v1/shortcut_service";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -23,7 +23,7 @@ const AnalyticsView: React.FC<Props> = (props: Props) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames("w-full", className)}>
|
<div className={classNames("relative w-full", className)}>
|
||||||
{analytics ? (
|
{analytics ? (
|
||||||
<>
|
<>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
@ -138,7 +138,7 @@ const AnalyticsView: React.FC<Props> = (props: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-12 w-full flex flex-row justify-center items-center opacity-80">
|
<div className="absolute py-12 w-full flex flex-row justify-center items-center opacity-80">
|
||||||
<Icon.Loader className="mr-2 w-5 h-auto animate-spin" />
|
<Icon.Loader className="mr-2 w-5 h-auto animate-spin" />
|
||||||
{t("common.loading")}
|
{t("common.loading")}
|
||||||
</div>
|
</div>
|
||||||
|
@ -2,8 +2,8 @@ import { Button, Input, Modal, ModalDialog } from "@mui/joy";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import useLoading from "../hooks/useLoading";
|
import useLoading from "@/hooks/useLoading";
|
||||||
import useUserStore from "../stores/v1/user";
|
import { useUserStore } from "@/stores";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -46,10 +46,13 @@ const ChangePasswordDialog: React.FC<Props> = (props: Props) => {
|
|||||||
|
|
||||||
requestState.setLoading();
|
requestState.setLoading();
|
||||||
try {
|
try {
|
||||||
userStore.patchUser({
|
userStore.patchUser(
|
||||||
|
{
|
||||||
id: userStore.getCurrentUser().id,
|
id: userStore.getCurrentUser().id,
|
||||||
password: newPassword,
|
password: newPassword,
|
||||||
});
|
},
|
||||||
|
["password"],
|
||||||
|
);
|
||||||
onClose();
|
onClose();
|
||||||
toast("Password changed");
|
toast("Password changed");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { Tooltip } from "@mui/joy";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import copy from "copy-to-clipboard";
|
import copy from "copy-to-clipboard";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@ -7,11 +8,9 @@ import { Link } from "react-router-dom";
|
|||||||
import { absolutifyLink } from "@/helpers/utils";
|
import { absolutifyLink } from "@/helpers/utils";
|
||||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||||
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
|
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
|
||||||
import useCollectionStore from "@/stores/v1/collection";
|
import { useCollectionStore, useShortcutStore, useUserStore } from "@/stores";
|
||||||
import useShortcutStore from "@/stores/v1/shortcut";
|
import { Collection } from "@/types/proto/api/v1/collection_service";
|
||||||
import useUserStore from "@/stores/v1/user";
|
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
||||||
import { Collection } from "@/types/proto/api/v2/collection_service";
|
|
||||||
import { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
|
||||||
import { showCommonDialog } from "./Alert";
|
import { showCommonDialog } from "./Alert";
|
||||||
import CreateCollectionDialog from "./CreateCollectionDrawer";
|
import CreateCollectionDialog from "./CreateCollectionDrawer";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
@ -57,13 +56,17 @@ const CollectionView = (props: Props) => {
|
|||||||
navigateTo(`/shortcut/${shortcut.id}`);
|
navigateTo(`/shortcut/${shortcut.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenAllShortcutsButtonClick = () => {
|
||||||
|
shortcuts.forEach((shortcut: Shortcut) => window.open(`/s/${shortcut.name}`));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={classNames("w-full flex flex-col justify-start items-start border rounded-lg hover:shadow dark:border-zinc-800")}>
|
<div className={classNames("w-full flex flex-col justify-start items-start border rounded-lg hover:shadow dark:border-zinc-800")}>
|
||||||
<div className="bg-gray-100 dark:bg-zinc-800 px-3 py-2 w-full flex flex-row justify-between items-center rounded-t-lg">
|
<div className="bg-gray-100 dark:bg-zinc-800 px-3 py-2 w-full flex flex-row justify-between items-center rounded-t-lg">
|
||||||
<div className="w-auto flex flex-col justify-start items-start mr-2">
|
<div className="w-auto flex flex-col justify-start items-start mr-2">
|
||||||
<div className="w-full truncate">
|
<div className="w-full truncate">
|
||||||
<Link className="leading-6 font-medium dark:text-gray-400" to={`/c/${collection.name}`} unstable_viewTransition>
|
<Link className="leading-6 font-medium dark:text-gray-400" to={`/c/${collection.name}`} viewTransition>
|
||||||
{collection.title}
|
{collection.title}
|
||||||
</Link>
|
</Link>
|
||||||
<span className="ml-1 leading-6 text-gray-500 dark:text-gray-400" onClick={handleCopyCollectionLink}>
|
<span className="ml-1 leading-6 text-gray-500 dark:text-gray-400" onClick={handleCopyCollectionLink}>
|
||||||
@ -73,9 +76,19 @@ const CollectionView = (props: Props) => {
|
|||||||
<p className="text-sm text-gray-500">{collection.description}</p>
|
<p className="text-sm text-gray-500">{collection.description}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row justify-end items-center shrink-0 gap-2">
|
<div className="flex flex-row justify-end items-center shrink-0 gap-2">
|
||||||
<Link className="w-full text-gray-400 cursor-pointer hover:text-gray-500" to={`/c/${collection.name}`} target="_blank">
|
<Tooltip title="Share" placement="top" arrow>
|
||||||
|
<Link className="w-auto text-gray-400 cursor-pointer hover:text-gray-500" to={`/c/${collection.name}`} target="_blank">
|
||||||
<Icon.Share className="w-4 h-auto" />
|
<Icon.Share className="w-4 h-auto" />
|
||||||
</Link>
|
</Link>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Open all" placement="top" arrow>
|
||||||
|
<button
|
||||||
|
className="w-auto text-gray-400 cursor-pointer hover:text-gray-500"
|
||||||
|
onClick={() => handleOpenAllShortcutsButtonClick()}
|
||||||
|
>
|
||||||
|
<Icon.ArrowUpRight className="w-5 h-auto" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
{showAdminActions && (
|
{showAdminActions && (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
trigger={
|
trigger={
|
||||||
|
@ -3,8 +3,8 @@ import { useState } from "react";
|
|||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { userServiceClient } from "@/grpcweb";
|
import { userServiceClient } from "@/grpcweb";
|
||||||
import useLoading from "../hooks/useLoading";
|
import useLoading from "@/hooks/useLoading";
|
||||||
import useUserStore from "../stores/v1/user";
|
import { useUserStore } from "@/stores";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
@ -1,15 +1,13 @@
|
|||||||
import { Button, DialogActions, DialogContent, DialogTitle, Drawer, Input, ModalClose, Radio, RadioGroup } from "@mui/joy";
|
import { Button, Checkbox, DialogActions, DialogContent, DialogTitle, Divider, Drawer, Input, ModalClose } from "@mui/joy";
|
||||||
import { isUndefined } from "lodash-es";
|
import { isUndefined } from "lodash-es";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import useCollectionStore from "@/stores/v1/collection";
|
import useLoading from "@/hooks/useLoading";
|
||||||
import useShortcutStore from "@/stores/v1/shortcut";
|
import { useCollectionStore, useShortcutStore, useWorkspaceStore } from "@/stores";
|
||||||
import { Collection } from "@/types/proto/api/v2/collection_service";
|
import { Collection } from "@/types/proto/api/v1/collection_service";
|
||||||
import { Visibility } from "@/types/proto/api/v2/common";
|
import { Visibility } from "@/types/proto/api/v1/common";
|
||||||
import { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
||||||
import { convertVisibilityFromPb } from "@/utils/visibility";
|
|
||||||
import useLoading from "../hooks/useLoading";
|
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
import ShortcutView from "./ShortcutView";
|
import ShortcutView from "./ShortcutView";
|
||||||
|
|
||||||
@ -26,11 +24,12 @@ interface State {
|
|||||||
const CreateCollectionDrawer: React.FC<Props> = (props: Props) => {
|
const CreateCollectionDrawer: React.FC<Props> = (props: Props) => {
|
||||||
const { onClose, onConfirm, collectionId } = props;
|
const { onClose, onConfirm, collectionId } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const workspaceStore = useWorkspaceStore();
|
||||||
const collectionStore = useCollectionStore();
|
const collectionStore = useCollectionStore();
|
||||||
const shortcutList = useShortcutStore().getShortcutList();
|
const shortcutList = useShortcutStore().getShortcutList();
|
||||||
const [state, setState] = useState<State>({
|
const [state, setState] = useState<State>({
|
||||||
collectionCreate: Collection.fromPartial({
|
collectionCreate: Collection.fromPartial({
|
||||||
visibility: Visibility.PRIVATE,
|
visibility: Visibility.WORKSPACE,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const [selectedShortcuts, setSelectedShortcuts] = useState<Shortcut[]>([]);
|
const [selectedShortcuts, setSelectedShortcuts] = useState<Shortcut[]>([]);
|
||||||
@ -49,6 +48,23 @@ const CreateCollectionDrawer: React.FC<Props> = (props: Props) => {
|
|||||||
})
|
})
|
||||||
.filter((shortcut) => !selectedShortcuts.find((selectedShortcut) => selectedShortcut.id === shortcut.id));
|
.filter((shortcut) => !selectedShortcuts.find((selectedShortcut) => selectedShortcut.id === shortcut.id));
|
||||||
|
|
||||||
|
const setPartialState = (partialState: Partial<State>) => {
|
||||||
|
setState({
|
||||||
|
...state,
|
||||||
|
...partialState,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (workspaceStore.setting.defaultVisibility !== Visibility.VISIBILITY_UNSPECIFIED) {
|
||||||
|
setPartialState({
|
||||||
|
collectionCreate: Object.assign(state.collectionCreate, {
|
||||||
|
visibility: workspaceStore.setting.defaultVisibility,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (collectionId) {
|
if (collectionId) {
|
||||||
@ -63,7 +79,7 @@ const CreateCollectionDrawer: React.FC<Props> = (props: Props) => {
|
|||||||
setSelectedShortcuts(
|
setSelectedShortcuts(
|
||||||
collection.shortcutIds
|
collection.shortcutIds
|
||||||
.map((shortcutId) => shortcutList.find((shortcut) => shortcut.id === shortcutId))
|
.map((shortcutId) => shortcutList.find((shortcut) => shortcut.id === shortcutId))
|
||||||
.filter(Boolean) as Shortcut[]
|
.filter(Boolean) as Shortcut[],
|
||||||
);
|
);
|
||||||
loadingState.setFinish();
|
loadingState.setFinish();
|
||||||
}
|
}
|
||||||
@ -75,13 +91,6 @@ const CreateCollectionDrawer: React.FC<Props> = (props: Props) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const setPartialState = (partialState: Partial<State>) => {
|
|
||||||
setState({
|
|
||||||
...state,
|
|
||||||
...partialState,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setPartialState({
|
setPartialState({
|
||||||
collectionCreate: Object.assign(state.collectionCreate, {
|
collectionCreate: Object.assign(state.collectionCreate, {
|
||||||
@ -98,14 +107,6 @@ const CreateCollectionDrawer: React.FC<Props> = (props: Props) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVisibilityInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setPartialState({
|
|
||||||
collectionCreate: Object.assign(state.collectionCreate, {
|
|
||||||
visibility: Number(e.target.value),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDescriptionInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleDescriptionInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setPartialState({
|
setPartialState({
|
||||||
collectionCreate: Object.assign(state.collectionCreate, {
|
collectionCreate: Object.assign(state.collectionCreate, {
|
||||||
@ -135,7 +136,7 @@ const CreateCollectionDrawer: React.FC<Props> = (props: Props) => {
|
|||||||
visibility: state.collectionCreate.visibility,
|
visibility: state.collectionCreate.visibility,
|
||||||
shortcutIds: selectedShortcuts.map((shortcut) => shortcut.id),
|
shortcutIds: selectedShortcuts.map((shortcut) => shortcut.id),
|
||||||
},
|
},
|
||||||
["name", "title", "description", "visibility", "shortcut_ids"]
|
["name", "title", "description", "visibility", "shortcut_ids"],
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await collectionStore.createCollection({
|
await collectionStore.createCollection({
|
||||||
@ -159,8 +160,8 @@ const CreateCollectionDrawer: React.FC<Props> = (props: Props) => {
|
|||||||
<Drawer anchor="right" open={true} onClose={onClose}>
|
<Drawer anchor="right" open={true} onClose={onClose}>
|
||||||
<DialogTitle>{isCreating ? "Create Collection" : "Edit Collection"}</DialogTitle>
|
<DialogTitle>{isCreating ? "Create Collection" : "Edit Collection"}</DialogTitle>
|
||||||
<ModalClose />
|
<ModalClose />
|
||||||
<DialogContent className="w-full max-w-full sm:max-w-[24rem]">
|
<DialogContent className="w-full max-w-full">
|
||||||
<div className="overflow-y-auto w-full mt-2 px-3 pb-4">
|
<div className="overflow-y-auto w-full mt-2 px-4 pb-4 sm:w-[24rem]">
|
||||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
<span className="mb-2">
|
<span className="mb-2">
|
||||||
Name <span className="text-red-600">*</span>
|
Name <span className="text-red-600">*</span>
|
||||||
@ -168,7 +169,8 @@ const CreateCollectionDrawer: React.FC<Props> = (props: Props) => {
|
|||||||
<Input
|
<Input
|
||||||
className="w-full"
|
className="w-full"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="The memorable name of the collection"
|
startDecorator="c/"
|
||||||
|
placeholder="An easy name to remember"
|
||||||
value={state.collectionCreate.name}
|
value={state.collectionCreate.name}
|
||||||
onChange={handleNameInputChange}
|
onChange={handleNameInputChange}
|
||||||
/>
|
/>
|
||||||
@ -181,7 +183,7 @@ const CreateCollectionDrawer: React.FC<Props> = (props: Props) => {
|
|||||||
<Input
|
<Input
|
||||||
className="w-full"
|
className="w-full"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="A short title to describe your collection"
|
placeholder="A short title of your collection"
|
||||||
value={state.collectionCreate.title}
|
value={state.collectionCreate.title}
|
||||||
onChange={handleTitleInputChange}
|
onChange={handleTitleInputChange}
|
||||||
/>
|
/>
|
||||||
@ -200,19 +202,21 @@ const CreateCollectionDrawer: React.FC<Props> = (props: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
<span className="mb-2">Visibility</span>
|
<Checkbox
|
||||||
<div className="w-full flex flex-row justify-start items-center text-base">
|
className="w-full dark:text-gray-400"
|
||||||
<RadioGroup orientation="horizontal" value={state.collectionCreate.visibility} onChange={handleVisibilityInputChange}>
|
checked={state.collectionCreate.visibility === Visibility.PUBLIC}
|
||||||
<Radio value={Visibility.PRIVATE} label={t(`shortcut.visibility.private.self`)} />
|
label={t(`shortcut.visibility.public.description`)}
|
||||||
<Radio value={Visibility.WORKSPACE} label={t(`shortcut.visibility.workspace.self`)} />
|
onChange={(e) =>
|
||||||
<Radio value={Visibility.PUBLIC} label={t(`shortcut.visibility.public.self`)} />
|
setPartialState({
|
||||||
</RadioGroup>
|
collectionCreate: Object.assign(state.collectionCreate, {
|
||||||
|
visibility: e.target.checked ? Visibility.PUBLIC : Visibility.WORKSPACE,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-sm text-gray-500 w-full bg-gray-100 border border-gray-200 dark:bg-zinc-800 dark:border-zinc-700 dark:text-gray-400 px-2 py-1 rounded-md">
|
<Divider className="text-gray-500" />
|
||||||
{t(`shortcut.visibility.${convertVisibilityFromPb(state.collectionCreate.visibility).toLowerCase()}.description`)}
|
<div className="w-full flex flex-col justify-start items-start mt-3 mb-3">
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
|
||||||
<p className="mb-2">
|
<p className="mb-2">
|
||||||
<span>Shortcuts</span>
|
<span>Shortcuts</span>
|
||||||
<span className="opacity-60">({selectedShortcuts.length})</span>
|
<span className="opacity-60">({selectedShortcuts.length})</span>
|
||||||
|
291
frontend/web/src/components/CreateIdentityProviderDrawer.tsx
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
import { Button, DialogActions, DialogContent, DialogTitle, Divider, Drawer, Input, ModalClose } from "@mui/joy";
|
||||||
|
import { isUndefined } from "lodash-es";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "react-hot-toast";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { workspaceServiceClient } from "@/grpcweb";
|
||||||
|
import { absolutifyLink } from "@/helpers/utils";
|
||||||
|
import useLoading from "@/hooks/useLoading";
|
||||||
|
import { useWorkspaceStore } from "@/stores";
|
||||||
|
import { IdentityProvider, IdentityProvider_Type, IdentityProviderConfig_OAuth2Config } from "@/types/proto/api/v1/workspace_service";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
identityProvider?: IdentityProvider;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
identityProviderCreate: IdentityProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreateIdentityProviderDrawer: React.FC<Props> = (props: Props) => {
|
||||||
|
const { onClose, onConfirm, identityProvider } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const workspaceStore = useWorkspaceStore();
|
||||||
|
const [state, setState] = useState<State>({
|
||||||
|
identityProviderCreate: IdentityProvider.fromPartial(
|
||||||
|
identityProvider || {
|
||||||
|
id: uuidv4(),
|
||||||
|
type: IdentityProvider_Type.OAUTH2,
|
||||||
|
config: {
|
||||||
|
oauth2: IdentityProviderConfig_OAuth2Config.fromPartial({
|
||||||
|
scopes: [],
|
||||||
|
fieldMapping: {},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const isCreating = isUndefined(identityProvider);
|
||||||
|
const requestState = useLoading(false);
|
||||||
|
|
||||||
|
const setPartialState = (partialState: Partial<State>) => {
|
||||||
|
setState({
|
||||||
|
...state,
|
||||||
|
...partialState,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTitleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPartialState({
|
||||||
|
identityProviderCreate: Object.assign(state.identityProviderCreate, {
|
||||||
|
title: e.target.value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOAuth2ConfigChange = (e: React.ChangeEvent<HTMLInputElement>, field: string) => {
|
||||||
|
if (!state.identityProviderCreate.config || !state.identityProviderCreate.config.oauth2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = field === "scopes" ? e.target.value.split(" ") : e.target.value;
|
||||||
|
setPartialState({
|
||||||
|
identityProviderCreate: Object.assign(state.identityProviderCreate, {
|
||||||
|
config: Object.assign(state.identityProviderCreate.config, {
|
||||||
|
oauth2: Object.assign(state.identityProviderCreate.config.oauth2, {
|
||||||
|
[field]: value,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFieldMappingChange = (e: React.ChangeEvent<HTMLInputElement>, field: string) => {
|
||||||
|
if (
|
||||||
|
!state.identityProviderCreate.config ||
|
||||||
|
!state.identityProviderCreate.config.oauth2 ||
|
||||||
|
!state.identityProviderCreate.config.oauth2.fieldMapping
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPartialState({
|
||||||
|
identityProviderCreate: Object.assign(state.identityProviderCreate, {
|
||||||
|
config: Object.assign(state.identityProviderCreate.config, {
|
||||||
|
oauth2: Object.assign(state.identityProviderCreate.config.oauth2, {
|
||||||
|
fieldMapping: Object.assign(state.identityProviderCreate.config.oauth2.fieldMapping, {
|
||||||
|
[field]: e.target.value,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSave = async () => {
|
||||||
|
if (!state.identityProviderCreate.id || !state.identityProviderCreate.title) {
|
||||||
|
toast.error("Please fill in required fields.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!isCreating) {
|
||||||
|
await workspaceServiceClient.updateWorkspaceSetting({
|
||||||
|
setting: {
|
||||||
|
identityProviders: workspaceStore.setting.identityProviders.map((idp) =>
|
||||||
|
idp.id === state.identityProviderCreate.id ? state.identityProviderCreate : idp,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
updateMask: ["identity_providers"],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await workspaceServiceClient.updateWorkspaceSetting({
|
||||||
|
setting: {
|
||||||
|
identityProviders: [...workspaceStore.setting.identityProviders, state.identityProviderCreate],
|
||||||
|
},
|
||||||
|
updateMask: ["identity_providers"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onConfirm) {
|
||||||
|
onConfirm();
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error(error.details);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer anchor="right" open={true} onClose={onClose}>
|
||||||
|
<DialogTitle>{isCreating ? "Create Identity Provider" : "Edit Identity Provider"}</DialogTitle>
|
||||||
|
<ModalClose />
|
||||||
|
<DialogContent className="w-full max-w-full">
|
||||||
|
<div className="overflow-y-auto w-full mt-2 px-4 pb-4 sm:w-[24rem]">
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2">
|
||||||
|
Title <span className="text-red-600">*</span>
|
||||||
|
</span>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="text"
|
||||||
|
placeholder="A short title will be displayed in the UI"
|
||||||
|
value={state.identityProviderCreate.title}
|
||||||
|
onChange={handleTitleInputChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Divider className="!mb-3" />
|
||||||
|
<p className="font-medium mb-2">Identity provider information</p>
|
||||||
|
{isCreating && (
|
||||||
|
<p className="shadow-sm rounded-md py-1 px-2 bg-zinc-100 dark:bg-zinc-900 text-sm w-full mb-2 break-all">
|
||||||
|
<span className="opacity-60">Redirect URL</span>
|
||||||
|
<br />
|
||||||
|
<code>{absolutifyLink("/auth/callback")}</code>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2">
|
||||||
|
Client ID <span className="text-red-600">*</span>
|
||||||
|
</span>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="text"
|
||||||
|
placeholder="Client ID of the OAuth2 provider"
|
||||||
|
value={state.identityProviderCreate.config?.oauth2?.clientId}
|
||||||
|
onChange={(e) => handleOAuth2ConfigChange(e, "clientId")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2">
|
||||||
|
Client Secret <span className="text-red-600">*</span>
|
||||||
|
</span>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="text"
|
||||||
|
placeholder="Client Secret of the OAuth2 provider"
|
||||||
|
value={state.identityProviderCreate.config?.oauth2?.clientSecret}
|
||||||
|
onChange={(e) => handleOAuth2ConfigChange(e, "clientSecret")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2">
|
||||||
|
Authorization endpoint <span className="text-red-600">*</span>
|
||||||
|
</span>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="text"
|
||||||
|
placeholder="Authorization endpoint of the OAuth2 provider"
|
||||||
|
value={state.identityProviderCreate.config?.oauth2?.authUrl}
|
||||||
|
onChange={(e) => handleOAuth2ConfigChange(e, "authUrl")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2">
|
||||||
|
Token endpoint <span className="text-red-600">*</span>
|
||||||
|
</span>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="text"
|
||||||
|
placeholder="Token endpoint of the OAuth2 provider"
|
||||||
|
value={state.identityProviderCreate.config?.oauth2?.tokenUrl}
|
||||||
|
onChange={(e) => handleOAuth2ConfigChange(e, "tokenUrl")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2">
|
||||||
|
User endpoint <span className="text-red-600">*</span>
|
||||||
|
</span>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="text"
|
||||||
|
placeholder="User endpoint of the OAuth2 provider"
|
||||||
|
value={state.identityProviderCreate.config?.oauth2?.userInfoUrl}
|
||||||
|
onChange={(e) => handleOAuth2ConfigChange(e, "userInfoUrl")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2">
|
||||||
|
Scopes <span className="text-red-600">*</span>
|
||||||
|
</span>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="text"
|
||||||
|
placeholder="Scopes of the OAuth2 provider, separated by space"
|
||||||
|
value={state.identityProviderCreate.config?.oauth2?.scopes.join(" ")}
|
||||||
|
onChange={(e) => handleOAuth2ConfigChange(e, "scopes")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Divider className="!mb-3" />
|
||||||
|
<p className="font-medium mb-2">Field mapping</p>
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2">
|
||||||
|
Identifier <span className="text-red-600">*</span>
|
||||||
|
</span>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="text"
|
||||||
|
placeholder="The field in the user info response to identify the user"
|
||||||
|
value={state.identityProviderCreate.config?.oauth2?.fieldMapping?.identifier}
|
||||||
|
onChange={(e) => handleFieldMappingChange(e, "identifier")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-col justify-start items-start">
|
||||||
|
<span className="mb-2">Display name</span>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="text"
|
||||||
|
placeholder="The field in the user info response to display the user"
|
||||||
|
value={state.identityProviderCreate.config?.oauth2?.fieldMapping?.displayName}
|
||||||
|
onChange={(e) => handleFieldMappingChange(e, "displayName")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<div className="w-full flex flex-row justify-end items-center px-3 py-4 space-x-2">
|
||||||
|
<Button color="neutral" variant="plain" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={onClose}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button color="primary" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={onSave}>
|
||||||
|
{t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogActions>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateIdentityProviderDrawer;
|
@ -1,26 +1,14 @@
|
|||||||
import {
|
import { Button, Checkbox, DialogActions, DialogContent, DialogTitle, Divider, Drawer, Input, ModalClose, Textarea } from "@mui/joy";
|
||||||
Button,
|
|
||||||
DialogActions,
|
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
|
||||||
Divider,
|
|
||||||
Drawer,
|
|
||||||
Input,
|
|
||||||
ModalClose,
|
|
||||||
Radio,
|
|
||||||
RadioGroup,
|
|
||||||
Textarea,
|
|
||||||
} from "@mui/joy";
|
|
||||||
import classnames from "classnames";
|
import classnames from "classnames";
|
||||||
import { isUndefined, uniq } from "lodash-es";
|
import { isUndefined, uniq } from "lodash-es";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import useShortcutStore, { getShortcutUpdateMask } from "@/stores/v1/shortcut";
|
import useLoading from "@/hooks/useLoading";
|
||||||
import { Visibility } from "@/types/proto/api/v2/common";
|
import { useShortcutStore, useWorkspaceStore } from "@/stores";
|
||||||
import { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
import { getShortcutUpdateMask } from "@/stores/shortcut";
|
||||||
import { convertVisibilityFromPb } from "@/utils/visibility";
|
import { Visibility } from "@/types/proto/api/v1/common";
|
||||||
import useLoading from "../hooks/useLoading";
|
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -39,7 +27,7 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [state, setState] = useState<State>({
|
const [state, setState] = useState<State>({
|
||||||
shortcutCreate: Shortcut.fromPartial({
|
shortcutCreate: Shortcut.fromPartial({
|
||||||
visibility: Visibility.PUBLIC,
|
visibility: Visibility.WORKSPACE,
|
||||||
ogMetadata: {
|
ogMetadata: {
|
||||||
title: "",
|
title: "",
|
||||||
description: "",
|
description: "",
|
||||||
@ -49,6 +37,7 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const shortcutStore = useShortcutStore();
|
const shortcutStore = useShortcutStore();
|
||||||
|
const workspaceStore = useWorkspaceStore();
|
||||||
const [showOpenGraphMetadata, setShowOpenGraphMetadata] = useState<boolean>(false);
|
const [showOpenGraphMetadata, setShowOpenGraphMetadata] = useState<boolean>(false);
|
||||||
const shortcutList = shortcutStore.getShortcutList();
|
const shortcutList = shortcutStore.getShortcutList();
|
||||||
const [tag, setTag] = useState<string>("");
|
const [tag, setTag] = useState<string>("");
|
||||||
@ -57,6 +46,23 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
|||||||
const loadingState = useLoading(!isCreating);
|
const loadingState = useLoading(!isCreating);
|
||||||
const requestState = useLoading(false);
|
const requestState = useLoading(false);
|
||||||
|
|
||||||
|
const setPartialState = (partialState: Partial<State>) => {
|
||||||
|
setState({
|
||||||
|
...state,
|
||||||
|
...partialState,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (workspaceStore.setting.defaultVisibility !== Visibility.VISIBILITY_UNSPECIFIED) {
|
||||||
|
setPartialState({
|
||||||
|
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||||
|
visibility: workspaceStore.setting.defaultVisibility,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (shortcutId) {
|
if (shortcutId) {
|
||||||
const shortcut = shortcutStore.getShortcutById(shortcutId);
|
const shortcut = shortcutStore.getShortcutById(shortcutId);
|
||||||
@ -82,13 +88,6 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const setPartialState = (partialState: Partial<State>) => {
|
|
||||||
setState({
|
|
||||||
...state,
|
|
||||||
...partialState,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setPartialState({
|
setPartialState({
|
||||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||||
@ -113,14 +112,6 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVisibilityInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setPartialState({
|
|
||||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
|
||||||
visibility: Number(e.target.value),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDescriptionInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleDescriptionInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setPartialState({
|
setPartialState({
|
||||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||||
@ -213,8 +204,8 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
|||||||
<Drawer anchor="right" open={true} onClose={onClose}>
|
<Drawer anchor="right" open={true} onClose={onClose}>
|
||||||
<DialogTitle>{isCreating ? "Create Shortcut" : "Edit Shortcut"}</DialogTitle>
|
<DialogTitle>{isCreating ? "Create Shortcut" : "Edit Shortcut"}</DialogTitle>
|
||||||
<ModalClose />
|
<ModalClose />
|
||||||
<DialogContent className="w-full max-w-full sm:max-w-[24rem]">
|
<DialogContent className="w-full max-w-full">
|
||||||
<div className="overflow-y-auto w-full mt-2 px-3 pb-4">
|
<div className="overflow-y-auto w-full mt-2 px-4 pb-4 sm:w-[24rem]">
|
||||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
<span className="mb-2">
|
<span className="mb-2">
|
||||||
Name <span className="text-red-600">*</span>
|
Name <span className="text-red-600">*</span>
|
||||||
@ -222,7 +213,8 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
|||||||
<Input
|
<Input
|
||||||
className="w-full"
|
className="w-full"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="The memorable name of the shortcut"
|
startDecorator="s/"
|
||||||
|
placeholder="An easy name to remember"
|
||||||
value={state.shortcutCreate.name}
|
value={state.shortcutCreate.name}
|
||||||
onChange={handleNameInputChange}
|
onChange={handleNameInputChange}
|
||||||
/>
|
/>
|
||||||
@ -280,24 +272,25 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
<span className="mb-2">Visibility</span>
|
<Checkbox
|
||||||
<div className="w-full flex flex-row justify-start items-center text-base">
|
className="w-full dark:text-gray-400"
|
||||||
<RadioGroup orientation="horizontal" value={state.shortcutCreate.visibility} onChange={handleVisibilityInputChange}>
|
checked={state.shortcutCreate.visibility === Visibility.PUBLIC}
|
||||||
<Radio value={Visibility.PRIVATE} label={t(`shortcut.visibility.private.self`)} />
|
label={t(`shortcut.visibility.public.description`)}
|
||||||
<Radio value={Visibility.WORKSPACE} label={t(`shortcut.visibility.workspace.self`)} />
|
onChange={(e) =>
|
||||||
<Radio value={Visibility.PUBLIC} label={t(`shortcut.visibility.public.self`)} />
|
setPartialState({
|
||||||
</RadioGroup>
|
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||||
</div>
|
visibility: e.target.checked ? Visibility.PUBLIC : Visibility.WORKSPACE,
|
||||||
<p className="mt-3 text-sm text-gray-500 w-full bg-gray-100 border border-gray-200 dark:bg-zinc-800 dark:border-zinc-700 dark:text-gray-400 px-2 py-1 rounded-md">
|
}),
|
||||||
{t(`shortcut.visibility.${convertVisibilityFromPb(state.shortcutCreate.visibility).toLowerCase()}.description`)}
|
})
|
||||||
</p>
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Divider className="text-gray-500">More</Divider>
|
<Divider className="text-gray-500">More</Divider>
|
||||||
<div className="w-full flex flex-col justify-start items-start border rounded-md mt-3 overflow-hidden dark:border-zinc-800">
|
<div className="w-full flex flex-col justify-start items-start border rounded-md mt-3 overflow-hidden dark:border-zinc-800">
|
||||||
<div
|
<div
|
||||||
className={classnames(
|
className={classnames(
|
||||||
"w-full flex flex-row justify-between items-center px-2 py-1 cursor-pointer hover:bg-gray-100 dark:hover:bg-zinc-800",
|
"w-full flex flex-row justify-between items-center px-2 py-1 cursor-pointer hover:bg-gray-100 dark:hover:bg-zinc-800",
|
||||||
showOpenGraphMetadata ? "bg-gray-100 border-b dark:bg-zinc-800 dark:border-b-zinc-700" : ""
|
showOpenGraphMetadata ? "bg-gray-100 border-b dark:bg-zinc-800 dark:border-b-zinc-700" : "",
|
||||||
)}
|
)}
|
||||||
onClick={() => setShowOpenGraphMetadata(!showOpenGraphMetadata)}
|
onClick={() => setShowOpenGraphMetadata(!showOpenGraphMetadata)}
|
||||||
>
|
>
|
||||||
@ -327,7 +320,7 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
|||||||
<Input
|
<Input
|
||||||
className="w-full"
|
className="w-full"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Slash - An open source, self-hosted bookmarks and link sharing platform"
|
placeholder="Slash - An open source, self-hosted platform for sharing and managing your most frequently used links"
|
||||||
size="sm"
|
size="sm"
|
||||||
value={state.shortcutCreate.ogMetadata?.title}
|
value={state.shortcutCreate.ogMetadata?.title}
|
||||||
onChange={handleOpenGraphMetadataTitleChange}
|
onChange={handleOpenGraphMetadataTitleChange}
|
||||||
@ -337,7 +330,7 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
|||||||
<span className="mb-2 text-sm">Description</span>
|
<span className="mb-2 text-sm">Description</span>
|
||||||
<Textarea
|
<Textarea
|
||||||
className="w-full"
|
className="w-full"
|
||||||
placeholder="An open source, self-hosted bookmarks and link sharing platform."
|
placeholder="An open source, self-hosted platform for sharing and managing your most frequently used links."
|
||||||
size="sm"
|
size="sm"
|
||||||
maxRows={3}
|
maxRows={3}
|
||||||
value={state.shortcutCreate.ogMetadata?.description}
|
value={state.shortcutCreate.ogMetadata?.description}
|
||||||
|
@ -3,9 +3,9 @@ import { isUndefined } from "lodash-es";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Role, User } from "@/types/proto/api/v2/user_service";
|
import useLoading from "@/hooks/useLoading";
|
||||||
import useLoading from "../hooks/useLoading";
|
import { useUserStore } from "@/stores";
|
||||||
import useUserStore from "../stores/v1/user";
|
import { Role, User } from "@/types/proto/api/v1/user_service";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -97,16 +97,20 @@ const CreateUserDialog: React.FC<Props> = (props: Props) => {
|
|||||||
const userPatch: Partial<User> = {
|
const userPatch: Partial<User> = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
};
|
};
|
||||||
|
const updateMask: string[] = [];
|
||||||
if (user.email !== state.userCreate.email) {
|
if (user.email !== state.userCreate.email) {
|
||||||
userPatch.email = state.userCreate.email;
|
userPatch.email = state.userCreate.email;
|
||||||
|
updateMask.push("email");
|
||||||
}
|
}
|
||||||
if (user.nickname !== state.userCreate.nickname) {
|
if (user.nickname !== state.userCreate.nickname) {
|
||||||
userPatch.nickname = state.userCreate.nickname;
|
userPatch.nickname = state.userCreate.nickname;
|
||||||
|
updateMask.push("nickname");
|
||||||
}
|
}
|
||||||
if (user.role !== state.userCreate.role) {
|
if (user.role !== state.userCreate.role) {
|
||||||
userPatch.role = state.userCreate.role;
|
userPatch.role = state.userCreate.role;
|
||||||
|
updateMask.push("role");
|
||||||
}
|
}
|
||||||
await userStore.patchUser(userPatch);
|
await userStore.patchUser(userPatch, updateMask);
|
||||||
} else {
|
} else {
|
||||||
await userStore.createUser(state.userCreate);
|
await userStore.createUser(state.userCreate);
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import useWorkspaceStore from "@/stores/v1/workspace";
|
import { useWorkspaceStore } from "@/stores";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
const DemoBanner: React.FC = () => {
|
const DemoBanner: React.FC = () => {
|
||||||
@ -10,7 +10,7 @@ const DemoBanner: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="z-10 relative flex flex-row items-center justify-center w-full py-2 text-sm sm:text-lg font-medium dark:text-gray-300 bg-white dark:bg-zinc-700 shadow">
|
<div className="z-10 relative flex flex-row items-center justify-center w-full py-2 text-sm sm:text-lg font-medium dark:text-gray-300 bg-white dark:bg-zinc-700 shadow">
|
||||||
<div className="w-full max-w-8xl px-4 md:px-12 flex flex-row justify-between items-center gap-x-3">
|
<div className="w-full max-w-8xl px-4 md:px-12 flex flex-row justify-between items-center gap-x-3">
|
||||||
<span>✨🔗 Slash - An open source, self-hosted bookmarks and link sharing platform</span>
|
<span>✨🔗 Slash - An open source, self-hosted platform for sharing and managing your most frequently used links.</span>
|
||||||
<a
|
<a
|
||||||
className="shadow flex flex-row justify-center items-center px-2 py-1 rounded-md text-sm sm:text-base text-white bg-blue-600 hover:bg-blue-700"
|
className="shadow flex flex-row justify-center items-center px-2 py-1 rounded-md text-sm sm:text-base text-white bg-blue-600 hover:bg-blue-700"
|
||||||
href="https://github.com/yourselfhosted/slash#deploy-with-docker-in-seconds"
|
href="https://github.com/yourselfhosted/slash#deploy-with-docker-in-seconds"
|
||||||
|
@ -2,8 +2,8 @@ import { Button, Input, Modal, ModalDialog } from "@mui/joy";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import useLoading from "../hooks/useLoading";
|
import useLoading from "@/hooks/useLoading";
|
||||||
import useUserStore from "../stores/v1/user";
|
import { useUserStore } from "@/stores";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -41,11 +41,14 @@ const EditUserinfoDialog: React.FC<Props> = (props: Props) => {
|
|||||||
|
|
||||||
requestState.setLoading();
|
requestState.setLoading();
|
||||||
try {
|
try {
|
||||||
await userStore.patchUser({
|
await userStore.patchUser(
|
||||||
|
{
|
||||||
id: currentUser.id,
|
id: currentUser.id,
|
||||||
email,
|
email,
|
||||||
nickname,
|
nickname,
|
||||||
});
|
},
|
||||||
|
["email", "nickname"],
|
||||||
|
);
|
||||||
onClose();
|
onClose();
|
||||||
toast("User information updated");
|
toast("User information updated");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
25
frontend/web/src/components/FeatureBadge.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { Tooltip } from "@mui/joy";
|
||||||
|
import { useWorkspaceStore } from "@/stores";
|
||||||
|
import { FeatureType } from "@/stores/workspace";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
feature: FeatureType;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeatureBadge = ({ feature, className }: Props) => {
|
||||||
|
const workspaceStore = useWorkspaceStore();
|
||||||
|
const isFeatureEnabled = workspaceStore.checkFeatureAvailable(feature);
|
||||||
|
|
||||||
|
if (isFeatureEnabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Tooltip title="This feature is not available on your plan." className={className} placement="top" arrow>
|
||||||
|
<Icon.Sparkles />
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeatureBadge;
|
@ -1,6 +1,5 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { convertVisibilityFromPb } from "@/utils/visibility";
|
import { useViewStore } from "@/stores";
|
||||||
import useViewStore from "../stores/v1/view";
|
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
import VisibilityIcon from "./VisibilityIcon";
|
import VisibilityIcon from "./VisibilityIcon";
|
||||||
|
|
||||||
@ -33,7 +32,7 @@ const FilterView = () => {
|
|||||||
onClick={() => viewStore.setFilter({ visibility: undefined })}
|
onClick={() => viewStore.setFilter({ visibility: undefined })}
|
||||||
>
|
>
|
||||||
<VisibilityIcon className="w-4 h-auto mr-1" visibility={filter.visibility} />
|
<VisibilityIcon className="w-4 h-auto mr-1" visibility={filter.visibility} />
|
||||||
{t(`shortcut.visibility.${convertVisibilityFromPb(filter.visibility).toLowerCase()}.self`)}
|
{t(`shortcut.visibility.${filter.visibility.toLowerCase()}.self`)}
|
||||||
<Icon.X className="w-4 h-auto ml-1" />
|
<Icon.X className="w-4 h-auto ml-1" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
@ -3,8 +3,8 @@ import { QRCodeCanvas } from "qrcode.react";
|
|||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
import { absolutifyLink } from "@/helpers/utils";
|
||||||
import { absolutifyLink } from "../helpers/utils";
|
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -25,12 +25,12 @@ const GenerateQRCodeDialog: React.FC<Props> = (props: Props) => {
|
|||||||
const handleDownloadQRCodeClick = () => {
|
const handleDownloadQRCodeClick = () => {
|
||||||
const canvas = containerRef.current?.querySelector("canvas");
|
const canvas = containerRef.current?.querySelector("canvas");
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
toast.error("Failed to get qr code canvas");
|
toast.error("Failed to get QR code canvas");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.download = "filename.png";
|
link.download = `${shortcut.title || shortcut.name}-qrcode.png`;
|
||||||
link.href = canvas.toDataURL();
|
link.href = canvas.toDataURL();
|
||||||
link.click();
|
link.click();
|
||||||
handleCloseBtnClick();
|
handleCloseBtnClick();
|
||||||
@ -47,7 +47,7 @@ const GenerateQRCodeDialog: React.FC<Props> = (props: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div ref={containerRef} className="w-full flex flex-row justify-center items-center mt-2 mb-6">
|
<div ref={containerRef} className="w-full flex flex-row justify-center items-center mt-2 mb-6">
|
||||||
<QRCodeCanvas value={shortcutLink} size={128} bgColor={"#ffffff"} fgColor={"#000000"} includeMargin={false} level={"L"} />
|
<QRCodeCanvas value={shortcutLink} size={180} bgColor={"#ffffff"} fgColor={"#000000"} includeMargin={false} level={"L"} />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full flex flex-row justify-center items-center px-4">
|
<div className="w-full flex flex-row justify-center items-center px-4">
|
||||||
<Button className="w-full" color="neutral" onClick={handleDownloadQRCodeClick}>
|
<Button className="w-full" color="neutral" onClick={handleDownloadQRCodeClick}>
|
||||||
|
@ -1,14 +1,13 @@
|
|||||||
import { Avatar } from "@mui/joy";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import { authServiceClient } from "@/grpcweb";
|
import { authServiceClient } from "@/grpcweb";
|
||||||
import useWorkspaceStore from "@/stores/v1/workspace";
|
import { useWorkspaceStore, useUserStore } from "@/stores";
|
||||||
import { PlanType } from "@/types/proto/api/v2/subscription_service";
|
import { PlanType } from "@/types/proto/api/v1/subscription_service";
|
||||||
import { Role } from "@/types/proto/api/v2/user_service";
|
import { Role } from "@/types/proto/api/v1/user_service";
|
||||||
import useUserStore from "../stores/v1/user";
|
|
||||||
import AboutDialog from "./AboutDialog";
|
import AboutDialog from "./AboutDialog";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
import Logo from "./Logo";
|
||||||
import Dropdown from "./common/Dropdown";
|
import Dropdown from "./common/Dropdown";
|
||||||
|
|
||||||
const Header: React.FC = () => {
|
const Header: React.FC = () => {
|
||||||
@ -17,10 +16,10 @@ const Header: React.FC = () => {
|
|||||||
const workspaceStore = useWorkspaceStore();
|
const workspaceStore = useWorkspaceStore();
|
||||||
const currentUser = useUserStore().getCurrentUser();
|
const currentUser = useUserStore().getCurrentUser();
|
||||||
const [showAboutDialog, setShowAboutDialog] = useState<boolean>(false);
|
const [showAboutDialog, setShowAboutDialog] = useState<boolean>(false);
|
||||||
const profile = workspaceStore.profile;
|
const subscription = workspaceStore.getSubscription();
|
||||||
const isAdmin = currentUser.role === Role.ADMIN;
|
const isAdmin = currentUser.role === Role.ADMIN;
|
||||||
const shouldShowRouterSwitch = location.pathname === "/" || location.pathname === "/collections" || location.pathname === "/memos";
|
const shouldShowRouterSwitch = location.pathname === "/shortcuts" || location.pathname === "/collections";
|
||||||
const selectedSection = location.pathname === "/" ? "Shortcuts" : location.pathname === "/collections" ? "Collections" : "Memos";
|
const selectedSection = location.pathname === "/shortcuts" ? "Shortcuts" : location.pathname === "/collections" ? "Collections" : "";
|
||||||
|
|
||||||
const handleSignOutButtonClick = async () => {
|
const handleSignOutButtonClick = async () => {
|
||||||
await authServiceClient.signOut({});
|
await authServiceClient.signOut({});
|
||||||
@ -29,16 +28,17 @@ const Header: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="w-full bg-gray-50 dark:bg-zinc-800 border-b border-b-gray-200 dark:border-b-zinc-800">
|
<div className="w-full bg-gray-50 dark:bg-black border-b border-b-gray-200 dark:border-b-zinc-800">
|
||||||
<div className="w-full max-w-8xl mx-auto px-3 md:px-12 py-3 flex flex-row justify-between items-center">
|
<div className="w-full max-w-8xl mx-auto px-4 sm:px-6 md:px-12 py-3 flex flex-row justify-between items-center">
|
||||||
<div className="flex flex-row justify-start items-center shrink mr-2">
|
<div className="flex flex-row justify-start items-center shrink mr-2">
|
||||||
<Link to="/" className="cursor-pointer flex flex-row justify-start items-center dark:text-gray-400" unstable_viewTransition>
|
<Link to="/" className="cursor-pointer flex flex-row justify-start items-center dark:text-gray-400" viewTransition>
|
||||||
<img id="logo-img" src="/logo.png" className="w-7 h-auto mr-2 -mt-0.5 dark:opacity-80 rounded-full shadow" alt="" />
|
<Logo className="mr-2" />
|
||||||
Slash
|
Slash
|
||||||
</Link>
|
</Link>
|
||||||
{profile.plan === PlanType.PRO && (
|
{[PlanType.PRO, PlanType.ENTERPRISE].includes(subscription.plan) && (
|
||||||
<span className="ml-1 text-xs px-1.5 leading-5 border rounded-full bg-blue-600 border-blue-700 text-white shadow dark:opacity-70">
|
<span className="ml-1 text-xs px-1.5 leading-5 border rounded-full bg-blue-600 border-blue-700 text-white shadow dark:opacity-70">
|
||||||
PRO
|
{/* PRO or ENT */}
|
||||||
|
{subscription.plan.substring(0, 3)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{shouldShowRouterSwitch && (
|
{shouldShowRouterSwitch && (
|
||||||
@ -56,15 +56,15 @@ const Header: React.FC = () => {
|
|||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
||||||
to="/"
|
to="/shortcuts"
|
||||||
unstable_viewTransition
|
viewTransition
|
||||||
>
|
>
|
||||||
<Icon.SquareSlash className="w-5 h-auto mr-2 opacity-70" /> Shortcuts
|
<Icon.SquareSlash className="w-5 h-auto mr-2 opacity-70" /> Shortcuts
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
||||||
to="/collections"
|
to="/collections"
|
||||||
unstable_viewTransition
|
viewTransition
|
||||||
>
|
>
|
||||||
<Icon.LibrarySquare className="w-5 h-auto mr-2 opacity-70" /> Collections
|
<Icon.LibrarySquare className="w-5 h-auto mr-2 opacity-70" /> Collections
|
||||||
</Link>
|
</Link>
|
||||||
@ -74,13 +74,12 @@ const Header: React.FC = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex-shrink-0">
|
<div className="relative shrink-0">
|
||||||
<Dropdown
|
<Dropdown
|
||||||
trigger={
|
trigger={
|
||||||
<button className="flex flex-row justify-end items-center cursor-pointer">
|
<button className="flex flex-row justify-end items-center cursor-pointer">
|
||||||
<Avatar size="sm" variant="plain" />
|
<span className="dark:text-gray-400 max-w-20 truncate">{currentUser.nickname}</span>
|
||||||
<span className="dark:text-gray-400">{currentUser.nickname}</span>
|
<Icon.ChevronDown className="ml-1 w-5 h-auto text-gray-600 dark:text-gray-400" />
|
||||||
<Icon.ChevronDown className="ml-2 w-5 h-auto text-gray-600 dark:text-gray-400" />
|
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
actionsClassName="!w-32"
|
actionsClassName="!w-32"
|
||||||
@ -89,7 +88,7 @@ const Header: React.FC = () => {
|
|||||||
<Link
|
<Link
|
||||||
className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
||||||
to="/setting/general"
|
to="/setting/general"
|
||||||
unstable_viewTransition
|
viewTransition
|
||||||
>
|
>
|
||||||
<Icon.User className="w-5 h-auto mr-2 opacity-70" /> {t("user.profile")}
|
<Icon.User className="w-5 h-auto mr-2 opacity-70" /> {t("user.profile")}
|
||||||
</Link>
|
</Link>
|
||||||
@ -97,7 +96,7 @@ const Header: React.FC = () => {
|
|||||||
<Link
|
<Link
|
||||||
className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
||||||
to="/setting/workspace"
|
to="/setting/workspace"
|
||||||
unstable_viewTransition
|
viewTransition
|
||||||
>
|
>
|
||||||
<Icon.Settings className="w-5 h-auto mr-2 opacity-70" /> {t("settings.self")}
|
<Icon.Settings className="w-5 h-auto mr-2 opacity-70" /> {t("settings.self")}
|
||||||
</Link>
|
</Link>
|
||||||
|
35
frontend/web/src/components/LinkFavicon.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFaviconUrlWithProvider = (url: string, provider: string) => {
|
||||||
|
try {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
searchParams.set("domain", new URL(url).hostname);
|
||||||
|
searchParams.set("sz", "64");
|
||||||
|
return new URL(`?${searchParams.toString()}`, provider).toString();
|
||||||
|
} catch (error) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const LinkFavicon = (props: Props) => {
|
||||||
|
const { url } = props;
|
||||||
|
const faviconProvider = "https://www.google.com/s2/favicons";
|
||||||
|
const [faviconUrl, setFaviconUrl] = useState<string>(getFaviconUrlWithProvider(url, faviconProvider));
|
||||||
|
|
||||||
|
const handleImgError = () => {
|
||||||
|
setFaviconUrl("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return faviconUrl ? (
|
||||||
|
<img className="w-full h-auto rounded" src={faviconUrl} decoding="async" loading="lazy" onError={handleImgError} />
|
||||||
|
) : (
|
||||||
|
<Icon.CircleSlash className="w-full h-auto text-gray-400" strokeWidth={1.5} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LinkFavicon;
|
25
frontend/web/src/components/Logo.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import classNames from "classnames";
|
||||||
|
import { useWorkspaceStore } from "@/stores";
|
||||||
|
import { FeatureType } from "@/stores/workspace";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Logo = ({ className }: Props) => {
|
||||||
|
const workspaceStore = useWorkspaceStore();
|
||||||
|
const hasCustomBranding = workspaceStore.checkFeatureAvailable(FeatureType.CustomeBranding);
|
||||||
|
const branding = hasCustomBranding && workspaceStore.setting.branding ? new TextDecoder().decode(workspaceStore.setting.branding) : "";
|
||||||
|
return (
|
||||||
|
<div className={classNames("w-8 h-auto dark:text-gray-500 rounded-lg overflow-hidden", className)}>
|
||||||
|
{branding ? (
|
||||||
|
<img src={branding} alt="branding" className="max-w-full max-h-full" />
|
||||||
|
) : (
|
||||||
|
<Icon.CircleSlash className="w-full h-auto" strokeWidth={1.5} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Logo;
|
86
frontend/web/src/components/PasswordAuthForm.tsx
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import { Button, Input } from "@mui/joy";
|
||||||
|
import { FormEvent, useState } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { authServiceClient } from "@/grpcweb";
|
||||||
|
import useLoading from "@/hooks/useLoading";
|
||||||
|
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||||
|
import { useUserStore } from "@/stores";
|
||||||
|
|
||||||
|
const PasswordAuthForm = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigateTo = useNavigateTo();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const actionBtnLoadingState = useLoading(false);
|
||||||
|
const allowConfirm = email.length > 0 && password.length > 0;
|
||||||
|
|
||||||
|
const handleEmailInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const text = e.target.value as string;
|
||||||
|
setEmail(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const text = e.target.value as string;
|
||||||
|
setPassword(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSigninBtnClick = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (actionBtnLoadingState.isLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
actionBtnLoadingState.setLoading();
|
||||||
|
const user = await authServiceClient.signIn({ email, password });
|
||||||
|
if (user) {
|
||||||
|
userStore.setCurrentUserId(user.id);
|
||||||
|
await userStore.fetchCurrentUser();
|
||||||
|
navigateTo("/");
|
||||||
|
} else {
|
||||||
|
toast.error("Signin failed");
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error(error.details);
|
||||||
|
}
|
||||||
|
actionBtnLoadingState.setFinish();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="w-full mt-6" onSubmit={handleSigninBtnClick}>
|
||||||
|
<div className={`flex flex-col justify-start items-start w-full ${actionBtnLoadingState.isLoading ? "opacity-80" : ""}`}>
|
||||||
|
<div className="w-full flex flex-col mb-2">
|
||||||
|
<span className="leading-8 mb-1 text-gray-600">{t("common.email")}</span>
|
||||||
|
<Input
|
||||||
|
className="w-full py-3"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
placeholder="slash@yourselfhosted.com"
|
||||||
|
onChange={handleEmailInputChanged}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-col mb-2">
|
||||||
|
<span className="leading-8 text-gray-600">{t("common.password")}</span>
|
||||||
|
<Input className="w-full py-3" type="password" value={password} placeholder="····" onChange={handlePasswordInputChanged} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-row justify-end items-center mt-4 space-x-2">
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
type="submit"
|
||||||
|
color="primary"
|
||||||
|
loading={actionBtnLoadingState.isLoading}
|
||||||
|
disabled={actionBtnLoadingState.isLoading || !allowConfirm}
|
||||||
|
onClick={handleSigninBtnClick}
|
||||||
|
>
|
||||||
|
{t("auth.sign-in")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PasswordAuthForm;
|