163 Commits

Author SHA1 Message Date
09f7c33135 chore: update version 2023-09-30 22:57:47 +08:00
fe3b78f844 chore: update i18n 2023-09-30 22:57:30 +08:00
0fd54426e6 chore: add create shortcut button when not found 2023-09-30 22:03:18 +08:00
690e14e4ed chore: update zh i18n 2023-09-30 09:03:52 +08:00
7795b17fd1 chore: add tags quick selector 2023-09-30 01:48:29 +08:00
c7dd4dc3eb chore: update extension version 2023-09-30 01:29:46 +08:00
6ee6a5166e chore: update dev server config 2023-09-30 01:23:25 +08:00
8c753e9557 feat: impl dark mode for extension 2023-09-30 01:22:40 +08:00
6126701025 chore: update favicon getter 2023-09-29 21:34:23 +08:00
8ef7d5f0d0 chore: update shortcut response styles 2023-09-29 21:17:06 +08:00
fa8d2f6639 chore: remove resource relative path setting 2023-09-29 19:55:13 +08:00
8cd976791e feat: get url favicon from google s2 2023-09-29 19:37:44 +08:00
010271c668 chore: update frontend deps 2023-09-29 19:10:01 +08:00
383d4f27f0 chore: fix subscription link 2023-09-25 06:18:59 +08:00
cb9786ef7c chore: fix title style in dark mode 2023-09-25 06:08:43 +08:00
e936bb6f15 chore: update subscription permission 2023-09-25 06:08:30 +08:00
60c440ae10 chore: fix check enable signup 2023-09-24 23:46:49 +08:00
fc8808ce04 chore: fix enable signup default value 2023-09-24 23:29:20 +08:00
e88327f2a3 chore: update resource service folder 2023-09-24 22:55:31 +08:00
159dfc9446 chore: add useNavigateTo hook 2023-09-24 21:21:34 +08:00
f78b072bb8 chore: update version 2023-09-24 19:44:31 +08:00
24fe368974 feat: implement subscription setting 2023-09-24 19:44:09 +08:00
46fa546a7d chore: update workspace setting store 2023-09-24 19:31:23 +08:00
96f6fa4257 chore: update user checks 2023-09-24 10:17:05 +08:00
8436d86661 chore: update workspace definition 2023-09-24 09:47:46 +08:00
a1d1e0f0f2 chore: update get workspace profile 2023-09-24 09:42:05 +08:00
0907ad2681 chore: update user setting store 2023-09-23 22:06:39 +08:00
e1b8bc607b chore: update web deps 2023-09-23 10:18:04 +08:00
528ecf72a3 chore: split setting pages 2023-09-23 09:02:10 +08:00
f0ffe2e419 chore: update workspace store 2023-09-23 08:54:27 +08:00
0df3164654 chore: update workspace setting fields 2023-09-23 01:59:21 +08:00
b97fb13929 chore: update styles 2023-09-23 01:25:08 +08:00
3488cd04c0 feat: implement dark mode 2023-09-23 01:09:45 +08:00
07e0bb2d4c chore: update golangci-lint config 2023-09-22 22:15:46 +08:00
a58ebd27ca chore: update grpcweb api error handler 2023-09-22 22:15:40 +08:00
d0a25e3ab2 chore: add feature matrix 2023-09-22 08:15:18 +08:00
92fba82927 chore: update server services 2023-09-22 07:44:44 +08:00
790a8a2e17 chore: fix linter 2023-09-22 00:37:04 +08:00
4e3d727b58 chore: add request cache 2023-09-22 00:13:11 +08:00
41eea8b571 feat: add subscription service 2023-09-21 23:09:38 +08:00
8f17abdbf0 feat: use get workspace profile in frontend 2023-09-21 08:46:48 +08:00
58cb5c7e2e chore: add get workspace profile api 2023-09-21 08:39:39 +08:00
271c133913 chore: rename workspace service 2023-09-21 08:25:55 +08:00
763205a89b chore: update workspace setting keys 2023-09-21 08:21:26 +08:00
e82e61d54d chore: update get workspace setting 2023-09-20 23:33:04 +08:00
0af4903657 feat: add custom style workspace setting 2023-09-20 23:13:30 +08:00
7f020eade9 fix: create access token 2023-09-20 21:56:56 +08:00
ebe54d1131 feat: impl grpcweb in frontend 2023-09-20 21:49:13 +08:00
9e8de4644a chore: fix github actions 2023-09-20 21:35:05 +08:00
a372d07c4b refactor: update ts proto generator 2023-09-20 21:28:36 +08:00
dd5cce63c5 chore: update generate access token 2023-09-20 21:15:30 +08:00
3c4155e6a1 chore: update frontend deps 2023-09-17 16:22:30 +08:00
6cb493b4a1 chore: rename context name 2023-09-17 16:21:11 +08:00
75d152922e feat: impl part of dark mode 2023-09-12 22:35:17 +08:00
908f95772d chore: update user settings 2023-09-12 22:03:06 +08:00
8992d48b3e feat: use workspace setting service in frontend 2023-09-12 21:34:05 +08:00
aa247ccef2 chore: impl workspace setting service 2023-09-12 21:02:33 +08:00
0ba373373d chore: fix proto linter 2023-09-12 08:51:31 +08:00
e843594a02 chore: initial workspace setting proto definition 2023-09-12 08:50:23 +08:00
032d9c1220 chore: update readme 2023-09-12 08:36:57 +08:00
e5e50b6874 chore: update readme 2023-09-12 08:36:10 +08:00
a7858075d8 chore: pnpm update 2023-09-12 08:25:43 +08:00
cff6c54b52 chore: add beta badge component 2023-09-06 00:06:04 +08:00
5e6190b181 feat: add color theme selector 2023-09-05 23:59:24 +08:00
b50e809125 feat: add color theme user setting definition 2023-09-05 23:48:32 +08:00
7348f47ef8 chore: update i18n locales 2023-09-05 22:10:23 +08:00
126e4a62f8 chore: update install guide 2023-09-05 21:00:06 +08:00
78282dab4d feat: support docker compose deployment (#18) 2023-09-05 20:59:19 +08:00
4a50248fbc chore: remove shorcut lower name 2023-09-05 20:55:02 +08:00
4f0a8cdc0a chore: implement i18n setting 2023-09-04 23:41:41 +08:00
a49a708fc5 chore: update extension tests 2023-09-03 14:52:30 +08:00
bb99341aba chore: add readme to locales 2023-09-03 14:50:25 +08:00
0ce934413a chore: update proto types for extension 2023-09-03 14:48:19 +08:00
65e366fdf1 chore: update ts definition generator 2023-09-03 14:42:12 +08:00
2fcd496fd2 chore: update ts definition generator 2023-09-03 14:35:50 +08:00
7cde25bdb5 chore: update user setting locale definition 2023-09-03 13:32:22 +08:00
35c396a88f feat: implement user setting service 2023-09-03 13:28:18 +08:00
a970d85e14 chore: add user setting service definition 2023-09-03 13:06:03 +08:00
4733e4796d chore: update user setting key 2023-09-03 10:43:28 +08:00
7c4ccbef3f chore: add auto backup workspace setting 2023-09-02 14:17:23 +08:00
b8f31cfd25 chore: add cron package 2023-09-02 14:11:23 +08:00
98cb5a2292 feat: add user locale setting 2023-09-02 14:10:03 +08:00
96c1901dce chore: regenerate pnpm-lock.yaml 2023-09-02 14:06:25 +08:00
b807417885 chore: upgrade deps version 2023-08-27 16:20:05 +08:00
6495c2081d chore: update readme features 2023-08-24 22:32:14 +08:00
0f92ccb22d chore: update readme 2023-08-24 21:48:52 +08:00
bdf7f327d2 fix: build extension action 2023-08-24 21:04:58 +08:00
efc3815edf chore: fix frontend build 2023-08-24 06:58:52 +08:00
f5817c575c chore: update frontend folder 2023-08-23 09:13:42 +08:00
40814a801a chore: update readme badges 2023-08-23 00:04:48 +08:00
e0f805f679 chore(i18n): new Crowdin updates (#29)
* Update source file en.json

* New translations en.json (English)

* New translations zh.json (English)

* Update source file en.json

* New translations zh.json (Chinese Simplified)

* Update source file zh.json

* New translations zh.json (Chinese Simplified)
2023-08-22 23:58:43 +08:00
c4fcfbd6aa chore(i18n): new Crowdin updates (#28)
* New translations en.json (Chinese Simplified)

* New translations en.json (English)
2023-08-22 23:45:37 +08:00
86d17188e1 Update Crowdin configuration file 2023-08-22 23:39:20 +08:00
88f8c00088 Update Crowdin configuration file 2023-08-22 23:30:21 +08:00
8612715371 chore: upgrade version to v0.4.3 2023-08-22 23:05:04 +08:00
e91050c803 chore: add delete user button 2023-08-22 23:02:14 +08:00
ec2ec74e31 feat: impl delete shortcut apiv2 2023-08-22 22:47:39 +08:00
bfb640f201 chore: update dialog width in extension 2023-08-22 09:27:49 +08:00
34f8a97309 feat: impl delete user apiv2 2023-08-22 09:15:27 +08:00
1c58702716 chore: fix linter warning 2023-08-21 02:03:16 +08:00
bd31c19a15 chore: fix typo 2023-08-21 01:55:16 +08:00
7e0ada6161 chore: add list users api 2023-08-21 01:53:52 +08:00
b5d6036fcf chore: update logger 2023-08-21 01:48:20 +08:00
0fcee9baf2 chore: update font family 2023-08-21 01:33:09 +08:00
f6fefdb8e6 chore: update joy-ui version 2023-08-21 00:15:41 +08:00
0ec06423e5 chore: update compact mode switch 2023-08-21 00:15:32 +08:00
8f028e4054 chore: add empty tags check 2023-08-21 00:07:11 +08:00
ae3b632f53 chore: remove analytics dialog 2023-08-18 22:52:57 +08:00
bafb17015c chore: update part of i18n 2023-08-18 09:00:42 +08:00
d939bb8250 chore: update version to 0.4.2 2023-08-16 19:15:44 +08:00
946548b33a fix: access token checks 2023-08-16 18:55:11 +08:00
d97a7e736d chore: add extension link to readme 2023-08-16 09:18:54 +08:00
e5d5ba5cbc chore: update extension version 2023-08-16 08:54:51 +08:00
ce4232c9f5 fix: create shortcut dialog height 2023-08-15 22:24:26 +08:00
bc6a72561c chore: update resource path setting checks 2023-08-15 21:38:02 +08:00
b9e5e7f2af feat: add resource service 2023-08-15 00:02:40 +08:00
96ab5b226d chore: fix method name 2023-08-13 00:00:14 +08:00
9c6f85e938 feat: add logo to extension 2023-08-12 00:47:08 +08:00
f1e3eace1a feat: add omnibox to extension 2023-08-11 00:26:04 +08:00
6f26523a11 chore: update extension docs 2023-08-11 00:20:39 +08:00
304a29a18c chore: upgrade extension version 2023-08-10 22:34:44 +08:00
3e5fa5573e chore: update options initial state check 2023-08-10 22:30:11 +08:00
93ed3c81ff fix: max width 2023-08-10 22:29:54 +08:00
0efd495f56 feat: add create shortcut button 2023-08-10 22:23:22 +08:00
ae56f6df8c chore: fix create shortcut 2023-08-10 22:23:12 +08:00
df51720310 chore: add server test 2023-08-10 20:57:43 +08:00
1194099667 feat: add create shortcut api 2023-08-09 23:31:52 +08:00
e936aaced1 chore: add create user api 2023-08-09 23:20:01 +08:00
0ee999a30a chore: update extension manifest 2023-08-09 09:04:41 +08:00
1211136037 chore: update resources 2023-08-09 09:04:13 +08:00
73061034b2 chore: update readme 2023-08-08 23:58:15 +08:00
07d1839112 chore: upgrade version to 0.4.1 2023-08-08 23:27:38 +08:00
876872f363 docs: add install browser extension 2023-08-08 23:21:15 +08:00
11e062549a fix: list shortcut api url 2023-08-08 23:00:05 +08:00
6a9fcb1c18 feat: implement list shortcuts v2 api 2023-08-08 22:48:10 +08:00
07365fda73 chore: update extension pnpm lock file 2023-08-08 22:01:07 +08:00
2264b64007 feat: implement extract shortcut name from url 2023-08-08 21:14:48 +08:00
bb389ad429 feat: update popup initial state handler 2023-08-08 20:53:15 +08:00
b6967abd08 chore: update air config 2023-08-08 20:52:37 +08:00
f886bd7eb8 feat: implement extension's popup and options 2023-08-08 20:16:14 +08:00
b638d9cdf4 chore: buf generate 2023-08-08 09:25:34 +08:00
8af0675247 chore: remove extension package action 2023-08-08 08:28:05 +08:00
fd09b18033 chore: fix extension test 2023-08-08 08:25:24 +08:00
129a9cf48c chore: add extension test action 2023-08-08 08:23:32 +08:00
feadf879dd chore: initial extension structure 2023-08-08 08:20:01 +08:00
9c134f4c8f chore: update access token list 2023-08-07 23:41:48 +08:00
b624576269 feat: implement shortcut service 2023-08-07 23:37:40 +08:00
2d980380e5 chore: update shortcut card padding 2023-08-07 20:22:40 +08:00
fda2a3436d chore: add copy button to access token 2023-08-07 20:08:47 +08:00
6f96e5e0c8 chore: update setting sections padding 2023-08-07 19:54:51 +08:00
dadf42c09b feat: allow to generate access token without expires time 2023-08-07 19:52:13 +08:00
e855f8c5ad chore: remove debug codes 2023-08-06 23:47:14 +08:00
01ec5900d4 feat: implement access tokens management in UI 2023-08-06 23:37:13 +08:00
850fbbaa36 chore: buf build es types 2023-08-06 21:43:34 +08:00
820b8fc379 chore: add expiration into create access token request 2023-08-06 21:33:16 +08:00
a90279221c feat: implement create&delete user access token api 2023-08-06 20:53:45 +08:00
ad988575b3 feat: implement get access tokens api 2023-08-06 20:25:23 +08:00
994a90c8fb chore: remove revoked field in access token 2023-08-06 14:28:35 +08:00
f33dcba284 chore: upgrade vite 2023-08-06 14:19:08 +08:00
84ddafeb84 feat: validate access token 2023-08-06 14:16:23 +08:00
d8903875d3 chore: update acl in api v2 2023-08-06 13:50:04 +08:00
fb3267d139 feat: add access tokens to user setting 2023-08-06 13:46:52 +08:00
aaed0a747f chore: update prettier setting 2023-08-05 21:28:50 +08:00
225 changed files with 25514 additions and 2377 deletions

1
.dockerignore Normal file
View File

@ -0,0 +1 @@
*/*/node_modules

View File

@ -1,4 +1,4 @@
name: Test name: Backend Test
on: on:
push: push:
@ -15,7 +15,7 @@ jobs:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: actions/setup-go@v3 - uses: actions/setup-go@v3
with: with:
go-version: 1.19 go-version: 1.21
check-latest: true check-latest: true
cache: true cache: true
- name: Verify go.mod is tidy - name: Verify go.mod is tidy
@ -34,7 +34,7 @@ jobs:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: actions/setup-go@v3 - uses: actions/setup-go@v3
with: with:
go-version: 1.19 go-version: 1.21
check-latest: true check-latest: true
cache: true cache: true
- name: Run all tests - name: Run all tests

54
.github/workflows/extension-test.yml vendored Normal file
View File

@ -0,0 +1,54 @@
name: Extension Test
on:
push:
branches:
- main
- "release/v*.*.*"
pull_request:
branches: [main]
paths:
- "frontend/extension/**"
jobs:
eslint-checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: bufbuild/buf-setup-action@v1
- run: buf generate
working-directory: proto
- uses: pnpm/action-setup@v2.2.4
with:
version: 8
- uses: actions/setup-node@v3
with:
node-version: "18"
cache: pnpm
cache-dependency-path: "frontend/extension/pnpm-lock.yaml"
- run: pnpm install
working-directory: frontend/extension
- name: Run eslint check
run: pnpm lint
working-directory: frontend/extension
extension-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: bufbuild/buf-setup-action@v1
- run: buf generate
working-directory: proto
- uses: pnpm/action-setup@v2.2.4
with:
version: 8
- uses: actions/setup-node@v3
with:
node-version: "18"
cache: pnpm
cache-dependency-path: "frontend/extension/pnpm-lock.yaml"
- run: pnpm install
working-directory: frontend/extension
- name: Run extension build
run: pnpm build
working-directory: frontend/extension

View File

@ -1,4 +1,4 @@
name: Test name: Frontend Test
on: on:
push: push:
@ -8,32 +8,37 @@ on:
pull_request: pull_request:
branches: [main] branches: [main]
paths: paths:
- "web/**" - "frontend/web/**"
jobs: jobs:
eslint-checks: eslint-checks:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: bufbuild/buf-setup-action@v1
- run: buf generate
working-directory: proto
- uses: pnpm/action-setup@v2.2.4 - uses: pnpm/action-setup@v2.2.4
with: with:
version: 8 version: 8
- uses: actions/checkout@v3
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: with:
node-version: "18" node-version: "18"
cache: pnpm cache: pnpm
cache-dependency-path: "web/pnpm-lock.yaml" cache-dependency-path: "frontend/web/pnpm-lock.yaml"
- run: pnpm install - run: pnpm install
working-directory: web working-directory: frontend/web
- name: Run eslint check - name: Run eslint check
run: pnpm lint run: pnpm lint
working-directory: web 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@v3
- uses: bufbuild/buf-setup-action@v1
- run: buf generate
working-directory: proto
- uses: pnpm/action-setup@v2.2.4 - uses: pnpm/action-setup@v2.2.4
with: with:
version: 8 version: 8
@ -41,9 +46,9 @@ jobs:
with: with:
node-version: "18" node-version: "18"
cache: pnpm cache: pnpm
cache-dependency-path: "web/pnpm-lock.yaml" cache-dependency-path: "frontend/web/pnpm-lock.yaml"
- run: pnpm install - run: pnpm install
working-directory: web working-directory: frontend/web
- name: Run frontend build - name: Run frontend build
run: pnpm build run: pnpm build
working-directory: web working-directory: frontend/web

5
.gitignore vendored
View File

@ -4,10 +4,9 @@
# temp folder # temp folder
tmp tmp
# Frontend asset
web/dist
# build folder # build folder
build build
.DS_Store .DS_Store
node_modules

View File

@ -1,5 +1,6 @@
linters: linters:
enable: enable:
- errcheck
- goimports - goimports
- revive - revive
- govet - govet
@ -10,17 +11,30 @@ linters:
- rowserrcheck - rowserrcheck
- nilerr - nilerr
- godot - godot
- forbidigo
- mirror
- bodyclose
issues: issues:
include:
# https://golangci-lint.run/usage/configuration/#command-line-options
exclude: exclude:
- Rollback - Rollback
- logger.Sync
- pgInstance.Stop
- fmt.Printf - fmt.Printf
- fmt.Print - Enter(.*)_(.*)
- Exit(.*)_(.*)
linters-settings: linters-settings:
goimports:
# Put imports beginning with prefix after 3rd-party packages.
local-prefixes: github.com/boojack/slash
revive: revive:
# Default to run all linters so that new rules in the future could automatically be added to the static check.
enable-all-rules: true enable-all-rules: true
rules: rules:
# The following rules are too strict and make coding harder. We do not enable them for now.
- name: file-header - name: file-header
disabled: true disabled: true
- name: line-length-limit - name: line-length-limit
@ -51,14 +65,22 @@ linters-settings:
disabled: true disabled: true
- name: early-return - name: early-return
disabled: true disabled: true
- name: exported
arguments:
- "disableStutteringCheck"
gocritic: gocritic:
disabled-checks: disabled-checks:
- ifElseChain - ifElseChain
govet: govet:
settings: settings:
printf: printf: # The name of the analyzer, run `go tool vet help` to see the list of all analyzers
funcs: funcs: # Run `go tool vet help printf` to see the full configuration of `printf`.
- common.Errorf - common.Errorf
enable-all: true
disable:
- fieldalignment
- shadow
forbidigo: forbidigo:
forbid: forbid:
- 'fmt\.Errorf(# Please use errors\.Wrap\|Wrapf\|Errorf instead)?' - 'fmt\.Errorf(# Please use errors\.Wrap\|Wrapf\|Errorf instead)?'
- 'ioutil\.ReadDir(# Please use os\.ReadDir)?'

View File

@ -1,26 +1,40 @@
# Build protobuf.
FROM golang:1.21-alpine AS protobuf
WORKDIR /protobuf-generate
COPY . .
RUN GO111MODULE=on GOBIN=/usr/local/bin go install github.com/bufbuild/buf/cmd/buf@v1.26.1
WORKDIR /protobuf-generate/proto
RUN buf generate
# Build frontend dist. # Build frontend dist.
FROM node:18.12.1-alpine3.16 AS frontend FROM node:18-alpine AS frontend
WORKDIR /frontend-build WORKDIR /frontend-build
COPY ./web/package.json ./web/pnpm-lock.yaml ./ COPY ./frontend .
COPY --from=protobuf /protobuf-generate/frontend/web/src/types/proto ./web/src/types/proto
WORKDIR /frontend-build/web
RUN corepack enable && pnpm i --frozen-lockfile RUN corepack enable && pnpm i --frozen-lockfile
COPY ./web/ .
RUN pnpm build RUN pnpm build
# Build backend exec file. # Build backend exec file.
FROM golang:1.19.3-alpine3.16 AS backend FROM golang:1.21-alpine AS backend
WORKDIR /backend-build WORKDIR /backend-build
COPY . . COPY . .
COPY --from=frontend /frontend-build/dist ./server/dist COPY --from=frontend /frontend-build/web/dist ./server/dist
RUN CGO_ENABLED=0 go build -o slash ./cmd/slash/main.go RUN CGO_ENABLED=0 go build -o slash ./cmd/slash/main.go
# Make workspace with above generated files. # Make workspace with above generated files.
FROM alpine:3.16 AS monolithic FROM alpine:latest AS monolithic
WORKDIR /usr/local/slash WORKDIR /usr/local/slash
RUN apk add --no-cache tzdata RUN apk add --no-cache tzdata

View File

@ -4,19 +4,24 @@
**Slash** is an open source, self-hosted bookmarks and link sharing platform. It allows you to organize your links with tags, and share them using custom shortened URLs. Slash also supports team sharing of link libraries for easy collaboration. **Slash** is an open source, self-hosted bookmarks and link sharing platform. It allows you to organize your links with tags, and share them using custom shortened URLs. Slash also supports team sharing of link libraries for easy collaboration.
<a href="https://demo.slash.yourselfhosted.com">Live Demo</a><a href="https://discord.gg/QZqUuUAhDV">Discord</a>
<p> <p>
<a href="https://discord.gg/QZqUuUAhDV"><img alt="Discord" src="https://img.shields.io/badge/discord-chat-5865f2?logo=discord&logoColor=f5f5f5" /></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>
<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://github.com/boojack/slash/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/boojack/slash?logo=github"/></a>
<a href="https://github.com/boojack/slash/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/boojack/slash?logo=github" /></a>
</p> </p>
<p align="center">
<a href="https://chrome.google.com/webstore/detail/slash/ebaiehmkammnacjadffpicipfckgeobg"><b>🧩 Browser extension now available!</b></a></p>
![demo](./resources/demo.png) ![demo](./resources/demo.png)
## Features ## Features
- Create customizable `/s/` short links for any URL. - Create customizable `/s/` short links for any URL.
- Share short links privately or with teammates. - Share short links public or only with your teammates.
- View analytics on link traffic and sources. - View analytics on link traffic and sources.
- Easy access to your shortcuts with browser extension.
- Open source self-hosted solution. - Open source self-hosted solution.
## Deploy with Docker in seconds ## Deploy with Docker in seconds
@ -26,3 +31,15 @@ docker run -d --name slash -p 5231:5231 -v ~/.slash/:/var/opt/slash yourselfhost
``` ```
Learn more in [Self-hosting Slash with Docker](https://github.com/boojack/slash/blob/main/docs/install.md). Learn more in [Self-hosting Slash with Docker](https://github.com/boojack/slash/blob/main/docs/install.md).
## Browser Extension
Slash provides a browser extension to help you use your shortcuts in the search bar to go to the corresponding URL.
![browser-extension-example](./resources/browser-extension-example.png)
### Chromium based browsers
For Chromium based browsers(Chrome, Edge, Arc, ...), you can install the extension from the [Chrome Web Store](https://chrome.google.com/webstore/detail/slash/ebaiehmkammnacjadffpicipfckgeobg).
Learn more in [The Browser Extension of Slash](https://github.com/boojack/slash/blob/main/docs/install-browser-extension.md).

View File

@ -1,7 +1,10 @@
package auth package auth
import ( import (
"fmt"
"time" "time"
"github.com/golang-jwt/jwt/v4"
) )
const ( const (
@ -16,9 +19,46 @@ const (
// CookieExpDuration expires slightly earlier than the jwt expiration. Client would be logged out if the user // CookieExpDuration expires slightly earlier than the jwt expiration. Client would be logged out if the user
// cookie expires, thus the client would always logout first before attempting to make a request with the expired jwt. // cookie expires, thus the client would always logout first before attempting to make a request with the expired jwt.
// Suppose we have a valid refresh token, we will refresh the token in cases:
// 1. The access token has already expired, we refresh the token so that the ongoing request can pass through.
CookieExpDuration = AccessTokenDuration - 1*time.Minute CookieExpDuration = AccessTokenDuration - 1*time.Minute
// AccessTokenCookieName is the cookie name of access token. // AccessTokenCookieName is the cookie name of access token.
AccessTokenCookieName = "slash.access-token" AccessTokenCookieName = "slash.access-token"
) )
type ClaimsMessage struct {
Name string `json:"name"`
jwt.RegisteredClaims
}
// GenerateAccessToken generates an access token.
// username is the email of the user.
func GenerateAccessToken(username string, userID int32, expirationTime time.Time, secret []byte) (string, error) {
return generateToken(username, userID, AccessTokenAudienceName, expirationTime, secret)
}
// generateToken generates a jwt token.
func generateToken(username string, userID int32, audience string, expirationTime time.Time, secret []byte) (string, error) {
registeredClaims := jwt.RegisteredClaims{
Issuer: Issuer,
Audience: jwt.ClaimStrings{audience},
IssuedAt: jwt.NewNumericDate(time.Now()),
Subject: fmt.Sprint(userID),
}
if !expirationTime.IsZero() {
registeredClaims.ExpiresAt = jwt.NewNumericDate(expirationTime)
}
// Declare the token with the HS256 algorithm used for signing, and the claims.
token := jwt.NewWithClaims(jwt.SigningMethodHS256, &ClaimsMessage{
Name: username,
RegisteredClaims: registeredClaims,
})
token.Header["kid"] = KeyID
// Create the JWT string.
tokenString, err := token.SignedString(secret)
if err != nil {
return "", err
}
return tokenString, nil
}

View File

@ -6,10 +6,11 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"github.com/boojack/slash/store"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/mssola/useragent" "github.com/mssola/useragent"
"golang.org/x/exp/slices" "golang.org/x/exp/slices"
"github.com/boojack/slash/store"
) )
type ReferenceInfo struct { type ReferenceInfo struct {

View File

@ -1,13 +1,20 @@
package v1 package v1
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"time"
"github.com/boojack/slash/store"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"github.com/boojack/slash/api/auth"
storepb "github.com/boojack/slash/proto/gen/store"
"github.com/boojack/slash/server/service/license"
"github.com/boojack/slash/store"
) )
type SignInRequest struct { type SignInRequest struct {
@ -46,24 +53,41 @@ func (s *APIV1Service) registerAuthRoutes(g *echo.Group, secret string) {
return echo.NewHTTPError(http.StatusUnauthorized, "unmatched email and password") return echo.NewHTTPError(http.StatusUnauthorized, "unmatched email and password")
} }
if err := GenerateTokensAndSetCookies(c, user, secret); err != nil { 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) 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)
return c.JSON(http.StatusOK, convertUserFromStore(user)) return c.JSON(http.StatusOK, convertUserFromStore(user))
}) })
g.POST("/auth/signup", func(c echo.Context) error { g.POST("/auth/signup", func(c echo.Context) error {
ctx := c.Request().Context() ctx := c.Request().Context()
disallowSignUpSetting, err := s.Store.GetWorkspaceSetting(ctx, &store.FindWorkspaceSetting{ enableSignUpSetting, err := s.Store.GetWorkspaceSetting(ctx, &store.FindWorkspaceSetting{
Key: store.WorkspaceDisallowSignUp, Key: storepb.WorkspaceSettingKey_WORKSAPCE_SETTING_ENABLE_SIGNUP,
}) })
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get workspace setting, err: %s", err)).SetInternal(err) return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get workspace setting, err: %s", err)).SetInternal(err)
} }
if disallowSignUpSetting != nil && disallowSignUpSetting.Value == "true" { if enableSignUpSetting != nil && !enableSignUpSetting.GetEnableSignup() {
return echo.NewHTTPError(http.StatusForbidden, "sign up has been disabled") 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{} signup := &SignUpRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(signup); err != nil { 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) return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("malformatted signup request, err: %s", err)).SetInternal(err)
@ -95,16 +119,90 @@ func (s *APIV1Service) registerAuthRoutes(g *echo.Group, secret string) {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create user, err: %s", err)).SetInternal(err) return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create user, err: %s", err)).SetInternal(err)
} }
if err := GenerateTokensAndSetCookies(c, user, secret); err != nil { 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) 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)
return c.JSON(http.StatusOK, convertUserFromStore(user)) return c.JSON(http.StatusOK, convertUserFromStore(user))
}) })
g.POST("/auth/logout", func(c echo.Context) error { g.POST("/auth/logout", func(c echo.Context) error {
ctx := c.Request().Context()
RemoveTokensAndCookies(c) 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) c.Response().WriteHeader(http.StatusOK)
return nil 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)
}

View File

@ -4,92 +4,23 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
"time"
"github.com/boojack/slash/api/auth"
"github.com/boojack/slash/internal/util"
"github.com/boojack/slash/store"
"github.com/golang-jwt/jwt/v4" "github.com/golang-jwt/jwt/v4"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/boojack/slash/api/auth"
"github.com/boojack/slash/internal/util"
storepb "github.com/boojack/slash/proto/gen/store"
"github.com/boojack/slash/store"
) )
const ( const (
// The key name used to store user id in the context // The key name used to store user id in the context
// user id is extracted from the jwt token subject field. // user id is extracted from the jwt token subject field.
UserIDContextKey = "user-id" userIDContextKey = "user-id"
) )
type claimsMessage struct {
Name string `json:"name"`
jwt.RegisteredClaims
}
// GenerateAccessToken generates an access token for web.
func GenerateAccessToken(username string, userID int32, secret string) (string, error) {
expirationTime := time.Now().Add(auth.AccessTokenDuration)
return generateToken(username, userID, auth.AccessTokenAudienceName, expirationTime, []byte(secret))
}
// GenerateTokensAndSetCookies generates jwt token and saves it to the http-only cookie.
func GenerateTokensAndSetCookies(c echo.Context, user *store.User, secret string) error {
accessToken, err := GenerateAccessToken(user.Email, user.ID, secret)
if err != nil {
return errors.Wrap(err, "failed to generate access token")
}
cookieExp := time.Now().Add(auth.CookieExpDuration)
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
return nil
}
// RemoveTokensAndCookies removes the jwt token and refresh 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)
}
// generateToken generates a jwt token.
func generateToken(username string, userID int32, aud string, expirationTime time.Time, secret []byte) (string, error) {
// Create the JWT claims, which includes the username and expiry time.
claims := &claimsMessage{
Name: username,
RegisteredClaims: jwt.RegisteredClaims{
Audience: jwt.ClaimStrings{aud},
// In JWT, the expiry time is expressed as unix milliseconds.
ExpiresAt: jwt.NewNumericDate(expirationTime),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: auth.Issuer,
Subject: fmt.Sprint(userID),
},
}
// Declare the token with the HS256 algorithm used for signing, and the claims.
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
token.Header["kid"] = auth.KeyID
// Create the JWT string.
tokenString, err := token.SignedString(secret)
if err != nil {
return "", err
}
return tokenString, nil
}
func extractTokenFromHeader(c echo.Context) (string, error) { func extractTokenFromHeader(c echo.Context) (string, error) {
authHeader := c.Request().Header.Get("Authorization") authHeader := c.Request().Header.Get("Authorization")
if authHeader == "" { if authHeader == "" {
@ -105,31 +36,20 @@ func extractTokenFromHeader(c echo.Context) (string, error) {
} }
func findAccessToken(c echo.Context) string { func findAccessToken(c echo.Context) string {
accessToken := "" // Check the HTTP request header first.
cookie, _ := c.Cookie(auth.AccessTokenCookieName) accessToken, _ := extractTokenFromHeader(c)
if cookie != nil {
accessToken = cookie.Value
}
if accessToken == "" { if accessToken == "" {
accessToken, _ = extractTokenFromHeader(c) // Check the cookie.
cookie, _ := c.Cookie(auth.AccessTokenCookieName)
if cookie != nil {
accessToken = cookie.Value
}
} }
return accessToken return accessToken
} }
func audienceContains(audience jwt.ClaimStrings, token string) bool {
for _, v := range audience {
if v == token {
return true
}
}
return false
}
// JWTMiddleware validates the access token. // JWTMiddleware validates the access token.
// If the access token is about to expire or has expired and the request has a valid refresh token, it func JWTMiddleware(s *APIV1Service, next echo.HandlerFunc, secret string) echo.HandlerFunc {
// will try to generate new access token and refresh token.
func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
ctx := c.Request().Context() ctx := c.Request().Context()
path := c.Request().URL.Path path := c.Request().URL.Path
@ -140,44 +60,30 @@ func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) e
return next(c) return next(c)
} }
token := findAccessToken(c) accessToken := findAccessToken(c)
if token == "" { if accessToken == "" {
// When the request is not authenticated, we allow the user to access the shortcut endpoints for those public shortcuts. // When the request is not authenticated, we allow the user to access the shortcut endpoints for those public shortcuts.
if util.HasPrefixes(path, "/s/*") && method == http.MethodGet { if util.HasPrefixes(path, "/s/") && method == http.MethodGet {
return next(c) return next(c)
} }
return echo.NewHTTPError(http.StatusUnauthorized, "Missing access token") return echo.NewHTTPError(http.StatusUnauthorized, "Missing access token")
} }
claims := &claimsMessage{} userID, err := getUserIDFromAccessToken(accessToken, secret)
_, err := jwt.ParseWithClaims(token, 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 { if err != nil {
RemoveTokensAndCookies(c) return echo.NewHTTPError(http.StatusUnauthorized, "Invalid or expired access token")
return echo.NewHTTPError(http.StatusUnauthorized, errors.Wrap(err, "Invalid or expired access token"))
}
if !audienceContains(claims.Audience, auth.AccessTokenAudienceName) {
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Invalid access token, audience mismatch, got %q, expected %q.", claims.Audience, auth.AccessTokenAudienceName))
} }
// We either have a valid access token or we will attempt to generate new access token and refresh token accessTokens, err := s.Store.GetUserAccessTokens(ctx, userID)
userID, err := util.ConvertStringToInt32(claims.Subject)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Malformed ID in the token.").WithInternal(err) 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. // Even if there is no error, we still need to make sure the user still exists.
user, err := server.Store.GetUser(ctx, &store.FindUser{ user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID, ID: &userID,
}) })
if err != nil { if err != nil {
@ -188,7 +94,40 @@ func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) e
} }
// Stores userID into context. // Stores userID into context.
c.Set(UserIDContextKey, userID) c.Set(userIDContextKey, userID)
return next(c) 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
}

View File

@ -8,10 +8,11 @@ import (
"net/url" "net/url"
"strings" "strings"
storepb "github.com/boojack/slash/proto/gen/store"
"github.com/boojack/slash/store"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/pkg/errors" "github.com/pkg/errors"
storepb "github.com/boojack/slash/proto/gen/store"
"github.com/boojack/slash/store"
) )
func (s *APIV1Service) registerRedirectorRoutes(g *echo.Group) { func (s *APIV1Service) registerRedirectorRoutes(g *echo.Group) {
@ -29,10 +30,10 @@ func (s *APIV1Service) registerRedirectorRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get shortcut, err: %s", err)).SetInternal(err) return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get shortcut, err: %s", err)).SetInternal(err)
} }
if shortcut == nil { if shortcut == nil {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with name: %s", shortcutName)) return c.Redirect(http.StatusSeeOther, fmt.Sprintf("/404?shortcut=%s", shortcutName))
} }
if shortcut.Visibility != storepb.Visibility_PUBLIC { if shortcut.Visibility != storepb.Visibility_PUBLIC {
userID, ok := c.Get(UserIDContextKey).(int32) userID, ok := c.Get(userIDContextKey).(int32)
if !ok { if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
} }

View File

@ -7,11 +7,12 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
"github.com/boojack/slash/internal/util" "github.com/boojack/slash/internal/util"
storepb "github.com/boojack/slash/proto/gen/store" storepb "github.com/boojack/slash/proto/gen/store"
"github.com/boojack/slash/store" "github.com/boojack/slash/store"
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
) )
// Visibility is the type of a shortcut visibility. // Visibility is the type of a shortcut visibility.
@ -81,7 +82,7 @@ type PatchShortcutRequest struct {
func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) { func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
g.POST("/shortcut", func(c echo.Context) error { g.POST("/shortcut", func(c echo.Context) error {
ctx := c.Request().Context() ctx := c.Request().Context()
userID, ok := c.Get(UserIDContextKey).(int32) userID, ok := c.Get(userIDContextKey).(int32)
if !ok { if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session") return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
} }
@ -90,20 +91,24 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("malformatted post shortcut request, err: %s", err)).SetInternal(err) return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("malformatted post shortcut request, err: %s", err)).SetInternal(err)
} }
shortcut, err := s.Store.CreateShortcut(ctx, &storepb.Shortcut{ shortcut := &storepb.Shortcut{
CreatorId: userID, CreatorId: userID,
Name: strings.ToLower(create.Name), Name: create.Name,
Link: create.Link, Link: create.Link,
Title: create.Title, Title: create.Title,
Description: create.Description, Description: create.Description,
Visibility: convertVisibilityToStorepb(create.Visibility), Visibility: convertVisibilityToStorepb(create.Visibility),
Tags: create.Tags, Tags: create.Tags,
OgMetadata: &storepb.OpenGraphMetadata{ OgMetadata: &storepb.OpenGraphMetadata{},
}
if create.OpenGraphMetadata != nil {
shortcut.OgMetadata = &storepb.OpenGraphMetadata{
Title: create.OpenGraphMetadata.Title, Title: create.OpenGraphMetadata.Title,
Description: create.OpenGraphMetadata.Description, Description: create.OpenGraphMetadata.Description,
Image: create.OpenGraphMetadata.Image, Image: create.OpenGraphMetadata.Image,
}, }
}) }
shortcut, err := s.Store.CreateShortcut(ctx, shortcut)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create shortcut, err: %s", err)).SetInternal(err) return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create shortcut, err: %s", err)).SetInternal(err)
} }
@ -125,7 +130,7 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut ID is not a number: %s", c.Param("shortcutId"))).SetInternal(err) 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) userID, ok := c.Get(userIDContextKey).(int32)
if !ok { if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session") return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
} }
@ -153,10 +158,6 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
if err := json.NewDecoder(c.Request().Body).Decode(patch); err != nil { 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) return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to decode patch shortcut request, err: %s", err)).SetInternal(err)
} }
if patch.Name != nil {
name := strings.ToLower(*patch.Name)
patch.Name = &name
}
shortcutUpdate := &store.UpdateShortcut{ shortcutUpdate := &store.UpdateShortcut{
ID: shortcutID, ID: shortcutID,
@ -196,7 +197,7 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
g.GET("/shortcut", func(c echo.Context) error { g.GET("/shortcut", func(c echo.Context) error {
ctx := c.Request().Context() ctx := c.Request().Context()
userID, ok := c.Get(UserIDContextKey).(int32) userID, ok := c.Get(userIDContextKey).(int32)
if !ok { if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session") return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
} }
@ -263,7 +264,7 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut id is not a number: %s", c.Param("id"))).SetInternal(err) 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) userID, ok := c.Get(userIDContextKey).(int32)
if !ok { if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session") return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
} }
@ -349,6 +350,8 @@ func convertVisibilityToStorepb(visibility Visibility) storepb.Visibility {
switch visibility { switch visibility {
case VisibilityPublic: case VisibilityPublic:
return storepb.Visibility_PUBLIC return storepb.Visibility_PUBLIC
case VisibilityWorkspace:
return storepb.Visibility_WORKSPACE
case VisibilityPrivate: case VisibilityPrivate:
return storepb.Visibility_PRIVATE return storepb.Visibility_PRIVATE
default: default:

View File

@ -1,31 +0,0 @@
package v1
import (
"fmt"
"net/http"
"github.com/labstack/echo/v4"
"go.deanishe.net/favicon"
)
func (*APIV1Service) registerURLUtilRoutes(g *echo.Group) {
// GET /url/favicon?url=...
g.GET("/url/favicon", func(c echo.Context) error {
url := c.QueryParam("url")
icons, err := favicon.Find(url)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to find favicon, err: %s", err))
}
availableIcons := []*favicon.Icon{}
for _, icon := range icons {
if icon.Width == icon.Height {
availableIcons = append(availableIcons, icon)
}
}
if len(availableIcons) == 0 {
return echo.NewHTTPError(http.StatusNotFound, "no favicon found")
}
return c.JSON(http.StatusOK, availableIcons[0].URL)
})
}

View File

@ -6,10 +6,13 @@ import (
"net/http" "net/http"
"net/mail" "net/mail"
"github.com/boojack/slash/internal/util"
"github.com/boojack/slash/store"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"github.com/boojack/slash/internal/util"
"github.com/boojack/slash/server/service/license"
"github.com/boojack/slash/store"
) )
const ( const (
@ -60,13 +63,13 @@ type CreateUserRequest struct {
func (create CreateUserRequest) Validate() error { func (create CreateUserRequest) Validate() error {
if create.Email != "" && !validateEmail(create.Email) { if create.Email != "" && !validateEmail(create.Email) {
return fmt.Errorf("invalid email format") return errors.New("invalid email format")
} }
if create.Nickname != "" && len(create.Nickname) < 3 { if create.Nickname != "" && len(create.Nickname) < 3 {
return fmt.Errorf("nickname is too short, minimum length is 3") return errors.New("nickname is too short, minimum length is 3")
} }
if len(create.Password) < 3 { if len(create.Password) < 3 {
return fmt.Errorf("password is too short, minimum length is 3") return errors.New("password is too short, minimum length is 3")
} }
return nil return nil
@ -83,7 +86,7 @@ type PatchUserRequest struct {
func (s *APIV1Service) registerUserRoutes(g *echo.Group) { func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
g.POST("/user", func(c echo.Context) error { g.POST("/user", func(c echo.Context) error {
ctx := c.Request().Context() ctx := c.Request().Context()
userID, ok := c.Get(UserIDContextKey).(int32) userID, ok := c.Get(userIDContextKey).(int32)
if !ok { if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session") return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
} }
@ -100,6 +103,16 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized to create user") 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{} userCreate := &CreateUserRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(userCreate); err != nil { if err := json.NewDecoder(c.Request().Body).Decode(userCreate); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user request").SetInternal(err) return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user request").SetInternal(err)
@ -144,7 +157,7 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
// GET /api/user/me is used to check if the user is logged in. // GET /api/user/me is used to check if the user is logged in.
g.GET("/user/me", func(c echo.Context) error { g.GET("/user/me", func(c echo.Context) error {
ctx := c.Request().Context() ctx := c.Request().Context()
userID, ok := c.Get(UserIDContextKey).(int32) userID, ok := c.Get(userIDContextKey).(int32)
if !ok { if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "missing auth session") return echo.NewHTTPError(http.StatusUnauthorized, "missing auth session")
} }
@ -182,7 +195,7 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err) 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) currentUserID, ok := c.Get(userIDContextKey).(int32)
if !ok { if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session") return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
} }
@ -254,7 +267,7 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
g.DELETE("/user/:id", func(c echo.Context) error { g.DELETE("/user/:id", func(c echo.Context) error {
ctx := c.Request().Context() ctx := c.Request().Context()
currentUserID, ok := c.Get(UserIDContextKey).(int32) currentUserID, ok := c.Get(userIDContextKey).(int32)
if !ok { if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session") return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
} }

View File

@ -2,7 +2,8 @@ package v1
import ( import (
"encoding/json" "encoding/json"
"fmt"
"github.com/pkg/errors"
) )
type UserSettingKey string type UserSettingKey string
@ -39,7 +40,7 @@ func (upsert UserSettingUpsert) Validate() error {
localeValue := "en" localeValue := "en"
err := json.Unmarshal([]byte(upsert.Value), &localeValue) err := json.Unmarshal([]byte(upsert.Value), &localeValue)
if err != nil { if err != nil {
return fmt.Errorf("failed to unmarshal user setting locale value") return errors.New("failed to unmarshal user setting locale value")
} }
invalid := true invalid := true
@ -50,10 +51,10 @@ func (upsert UserSettingUpsert) Validate() error {
} }
} }
if invalid { if invalid {
return fmt.Errorf("invalid user setting locale value") return errors.New("invalid user setting locale value")
} }
} else { } else {
return fmt.Errorf("invalid user setting key") return errors.New("invalid user setting key")
} }
return nil return nil

View File

@ -1,20 +1,24 @@
package v1 package v1
import ( import (
"github.com/boojack/slash/server/profile"
"github.com/boojack/slash/store"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/boojack/slash/server/profile"
"github.com/boojack/slash/server/service/license"
"github.com/boojack/slash/store"
) )
type APIV1Service struct { type APIV1Service struct {
Profile *profile.Profile Profile *profile.Profile
Store *store.Store Store *store.Store
LicenseService *license.LicenseService
} }
func NewAPIV1Service(profile *profile.Profile, store *store.Store) *APIV1Service { func NewAPIV1Service(profile *profile.Profile, store *store.Store, licenseService *license.LicenseService) *APIV1Service {
return &APIV1Service{ return &APIV1Service{
Profile: profile, Profile: profile,
Store: store, Store: store,
LicenseService: licenseService,
} }
} }
@ -23,7 +27,6 @@ func (s *APIV1Service) Start(apiGroup *echo.Group, secret string) {
apiV1Group.Use(func(next echo.HandlerFunc) echo.HandlerFunc { apiV1Group.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return JWTMiddleware(s, next, secret) return JWTMiddleware(s, next, secret)
}) })
s.registerURLUtilRoutes(apiV1Group)
s.registerWorkspaceRoutes(apiV1Group) s.registerWorkspaceRoutes(apiV1Group)
s.registerAuthRoutes(apiV1Group, secret) s.registerAuthRoutes(apiV1Group, secret)
s.registerUserRoutes(apiV1Group) s.registerUserRoutes(apiV1Group)

View File

@ -1,39 +1,16 @@
package v1 package v1
import ( import (
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"github.com/labstack/echo/v4"
storepb "github.com/boojack/slash/proto/gen/store"
"github.com/boojack/slash/server/profile" "github.com/boojack/slash/server/profile"
"github.com/boojack/slash/store" "github.com/boojack/slash/store"
"github.com/labstack/echo/v4"
) )
type WorkspaceSetting struct {
Key string `json:"key"`
Value string `json:"value"`
}
type WorkspaceSettingUpsert struct {
Key string `json:"key"`
Value string `json:"value"`
}
func (upsert WorkspaceSettingUpsert) Validate() error {
if upsert.Key == store.WorkspaceDisallowSignUp.String() {
value := false
err := json.Unmarshal([]byte(upsert.Value), &value)
if err != nil {
return fmt.Errorf("failed to unmarshal workspace setting disallow signup value")
}
} else {
return fmt.Errorf("invalid workspace setting key")
}
return nil
}
type WorkspaceProfile struct { type WorkspaceProfile struct {
Profile *profile.Profile `json:"profile"` Profile *profile.Profile `json:"profile"`
DisallowSignUp bool `json:"disallowSignUp"` DisallowSignUp bool `json:"disallowSignUp"`
@ -47,87 +24,16 @@ func (s *APIV1Service) registerWorkspaceRoutes(g *echo.Group) {
DisallowSignUp: false, DisallowSignUp: false,
} }
disallowSignUpSetting, err := s.Store.GetWorkspaceSetting(ctx, &store.FindWorkspaceSetting{ enableSignUpSetting, err := s.Store.GetWorkspaceSetting(ctx, &store.FindWorkspaceSetting{
Key: store.WorkspaceDisallowSignUp, Key: storepb.WorkspaceSettingKey_WORKSAPCE_SETTING_ENABLE_SIGNUP,
}) })
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find workspace setting, err: %s", err)).SetInternal(err) return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find workspace setting, err: %s", err)).SetInternal(err)
} }
if disallowSignUpSetting != nil { if enableSignUpSetting != nil {
workspaceProfile.DisallowSignUp = disallowSignUpSetting.Value == "true" workspaceProfile.DisallowSignUp = !enableSignUpSetting.GetEnableSignup()
} }
return c.JSON(http.StatusOK, workspaceProfile) return c.JSON(http.StatusOK, workspaceProfile)
}) })
g.POST("/workspace/setting", 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")
}
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 || user.Role != store.RoleAdmin {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
upsert := &WorkspaceSettingUpsert{}
if err := json.NewDecoder(c.Request().Body).Decode(upsert); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to decode request body, err: %s", err)).SetInternal(err)
}
if err := upsert.Validate(); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid request body, err: %s", err)).SetInternal(err)
}
workspaceSetting, err := s.Store.UpsertWorkspaceSetting(ctx, &store.WorkspaceSetting{
Key: store.WorkspaceSettingKey(upsert.Key),
Value: upsert.Value,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert workspace setting, err: %s", err)).SetInternal(err)
}
return c.JSON(http.StatusOK, convertWorkspaceSettingFromStore(workspaceSetting))
})
g.GET("/workspace/setting", 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")
}
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 || user.Role != store.RoleAdmin {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
list, err := s.Store.ListWorkspaceSettings(ctx, &store.FindWorkspaceSetting{})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to list workspace settings, err: %s", err)).SetInternal(err)
}
workspaceSettingList := []*WorkspaceSetting{}
for _, workspaceSetting := range list {
workspaceSettingList = append(workspaceSettingList, convertWorkspaceSettingFromStore(workspaceSetting))
}
return c.JSON(http.StatusOK, workspaceSettingList)
})
}
func convertWorkspaceSettingFromStore(workspaceSetting *store.WorkspaceSetting) *WorkspaceSetting {
return &WorkspaceSetting{
Key: workspaceSetting.Key.String(),
Value: workspaceSetting.Value,
}
} }

View File

@ -3,52 +3,40 @@ package v2
import ( import (
"context" "context"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time"
"github.com/boojack/slash/api/auth"
"github.com/boojack/slash/internal/util"
"github.com/boojack/slash/store"
"github.com/golang-jwt/jwt/v4" "github.com/golang-jwt/jwt/v4"
"github.com/pkg/errors" "github.com/pkg/errors"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata" "google.golang.org/grpc/metadata"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"github.com/boojack/slash/api/auth"
"github.com/boojack/slash/internal/util"
storepb "github.com/boojack/slash/proto/gen/store"
"github.com/boojack/slash/store"
) )
var authenticationAllowlistMethods = map[string]bool{
"/memos.api.v2.UserService/GetUser": true,
}
// IsAuthenticationAllowed returns whether the method is exempted from authentication.
func IsAuthenticationAllowed(fullMethodName string) bool {
if strings.HasPrefix(fullMethodName, "/grpc.reflection") {
return true
}
return authenticationAllowlistMethods[fullMethodName]
}
// ContextKey is the key type of context value. // ContextKey is the key type of context value.
type ContextKey int type ContextKey int
const ( const (
// The key name used to store user id in the context // The key name used to store user id in the context
// user id is extracted from the jwt token subject field. // user id is extracted from the jwt token subject field.
UserIDContextKey ContextKey = iota userIDContextKey ContextKey = iota
) )
// GRPCAuthInterceptor is the auth interceptor for gRPC server. // GRPCAuthInterceptor is the auth interceptor for gRPC server.
type GRPCAuthInterceptor struct { type GRPCAuthInterceptor struct {
store *store.Store Store *store.Store
secret string secret string
} }
// NewGRPCAuthInterceptor returns a new API auth interceptor. // NewGRPCAuthInterceptor returns a new API auth interceptor.
func NewGRPCAuthInterceptor(store *store.Store, secret string) *GRPCAuthInterceptor { func NewGRPCAuthInterceptor(store *store.Store, secret string) *GRPCAuthInterceptor {
return &GRPCAuthInterceptor{ return &GRPCAuthInterceptor{
store: store, Store: store,
secret: secret, secret: secret,
} }
} }
@ -59,30 +47,42 @@ func (in *GRPCAuthInterceptor) AuthenticationInterceptor(ctx context.Context, re
if !ok { if !ok {
return nil, status.Errorf(codes.Unauthenticated, "failed to parse metadata from incoming context") return nil, status.Errorf(codes.Unauthenticated, "failed to parse metadata from incoming context")
} }
accessTokenStr, err := getTokenFromMetadata(md) accessToken, err := getTokenFromMetadata(md)
if err != nil { if err != nil {
return nil, status.Errorf(codes.Unauthenticated, err.Error()) return nil, status.Errorf(codes.Unauthenticated, "failed to get access token from metadata: %v", err)
} }
userID, err := in.authenticate(ctx, accessTokenStr) userID, err := in.authenticate(ctx, accessToken)
if err != nil { if err != nil {
if IsAuthenticationAllowed(serverInfo.FullMethod) { if isUnauthorizeAllowedMethod(serverInfo.FullMethod) {
return handler(ctx, request) return handler(ctx, request)
} }
return nil, err return nil, err
} }
user, err := in.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return nil, errors.Wrap(err, "failed to get user")
}
if user == nil {
return nil, status.Errorf(codes.Unauthenticated, "user ID %q not exists in the access token", userID)
}
if isOnlyForAdminAllowedMethod(serverInfo.FullMethod) && user.Role != store.RoleAdmin {
return nil, status.Errorf(codes.PermissionDenied, "user ID %q is not admin", userID)
}
// Stores userID into context. // Stores userID into context.
childCtx := context.WithValue(ctx, UserIDContextKey, userID) childCtx := context.WithValue(ctx, userIDContextKey, userID)
return handler(childCtx, request) return handler(childCtx, request)
} }
func (in *GRPCAuthInterceptor) authenticate(ctx context.Context, accessTokenStr string) (int32, error) { func (in *GRPCAuthInterceptor) authenticate(ctx context.Context, accessToken string) (int32, error) {
if accessTokenStr == "" { if accessToken == "" {
return 0, status.Errorf(codes.Unauthenticated, "access token not found") return 0, status.Errorf(codes.Unauthenticated, "access token not found")
} }
claims := &claimsMessage{} claims := &auth.ClaimsMessage{}
_, err := jwt.ParseWithClaims(accessTokenStr, claims, func(t *jwt.Token) (any, error) { _, err := jwt.ParseWithClaims(accessToken, claims, func(t *jwt.Token) (any, error) {
if t.Method.Alg() != jwt.SigningMethodHS256.Name { if t.Method.Alg() != jwt.SigningMethodHS256.Name {
return nil, status.Errorf(codes.Unauthenticated, "unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256) return nil, status.Errorf(codes.Unauthenticated, "unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
} }
@ -108,7 +108,7 @@ func (in *GRPCAuthInterceptor) authenticate(ctx context.Context, accessTokenStr
if err != nil { if err != nil {
return 0, status.Errorf(codes.Unauthenticated, "malformed ID %q in the access token", claims.Subject) return 0, status.Errorf(codes.Unauthenticated, "malformed ID %q in the access token", claims.Subject)
} }
user, err := in.store.GetUser(ctx, &store.FindUser{ user, err := in.Store.GetUser(ctx, &store.FindUser{
ID: &userID, ID: &userID,
}) })
if err != nil { if err != nil {
@ -121,19 +121,28 @@ func (in *GRPCAuthInterceptor) authenticate(ctx context.Context, accessTokenStr
return 0, status.Errorf(codes.Unauthenticated, "user ID %q has been deactivated by administrators", userID) return 0, status.Errorf(codes.Unauthenticated, "user ID %q has been deactivated by administrators", userID)
} }
accessTokens, err := in.Store.GetUserAccessTokens(ctx, user.ID)
if err != nil {
return 0, errors.Wrapf(err, "failed to get user access tokens")
}
if !validateAccessToken(accessToken, accessTokens) {
return 0, status.Errorf(codes.Unauthenticated, "invalid access token")
}
return userID, nil return userID, nil
} }
func getTokenFromMetadata(md metadata.MD) (string, error) { func getTokenFromMetadata(md metadata.MD) (string, error) {
// Try to get the token from the authorization header first.
authorizationHeaders := md.Get("Authorization") authorizationHeaders := md.Get("Authorization")
if len(md.Get("Authorization")) > 0 { if len(authorizationHeaders) > 0 {
authHeaderParts := strings.Fields(authorizationHeaders[0]) authHeaderParts := strings.Fields(authorizationHeaders[0])
if len(authHeaderParts) != 2 || strings.ToLower(authHeaderParts[0]) != "bearer" { if len(authHeaderParts) != 2 || strings.ToLower(authHeaderParts[0]) != "bearer" {
return "", errors.Errorf("authorization header format must be Bearer {token}") return "", errors.Errorf("authorization header format must be Bearer {token}")
} }
return authHeaderParts[1], nil return authHeaderParts[1], nil
} }
// check the HTTP cookie // Try to get the token from the cookie header.
var accessToken string var accessToken string
for _, t := range append(md.Get("grpcgateway-cookie"), md.Get("cookie")...) { for _, t := range append(md.Get("grpcgateway-cookie"), md.Get("cookie")...) {
header := http.Header{} header := http.Header{}
@ -155,40 +164,11 @@ func audienceContains(audience jwt.ClaimStrings, token string) bool {
return false return false
} }
type claimsMessage struct { func validateAccessToken(accessTokenString string, userAccessTokens []*storepb.AccessTokensUserSetting_AccessToken) bool {
Name string `json:"name"` for _, userAccessToken := range userAccessTokens {
jwt.RegisteredClaims if accessTokenString == userAccessToken.AccessToken {
} return true
}
// GenerateAccessToken generates an access token for web.
func GenerateAccessToken(username string, userID int, secret string) (string, error) {
expirationTime := time.Now().Add(auth.AccessTokenDuration)
return generateToken(username, userID, auth.AccessTokenAudienceName, expirationTime, []byte(secret))
}
func generateToken(username string, userID int, aud string, expirationTime time.Time, secret []byte) (string, error) {
// Create the JWT claims, which includes the username and expiry time.
claims := &claimsMessage{
Name: username,
RegisteredClaims: jwt.RegisteredClaims{
Audience: jwt.ClaimStrings{aud},
// In JWT, the expiry time is expressed as unix milliseconds.
ExpiresAt: jwt.NewNumericDate(expirationTime),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: auth.Issuer,
Subject: strconv.Itoa(userID),
},
} }
return false
// Declare the token with the HS256 algorithm used for signing, and the claims.
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
token.Header["kid"] = auth.KeyID
// Create the JWT string.
tokenString, err := token.SignedString(secret)
if err != nil {
return "", err
}
return tokenString, nil
} }

27
api/v2/acl_config.go Normal file
View File

@ -0,0 +1,27 @@
package v2
import "strings"
var allowedMethodsWhenUnauthorized = map[string]bool{
"/slash.api.v2.WorkspaceService/GetWorkspaceProfile": true,
"/slash.api.v2.WorkspaceService/GetWorkspaceSetting": 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,
}
// isOnlyForAdminAllowedMethod returns true if the method is allowed to be called only by admin.
func isOnlyForAdminAllowedMethod(methodName string) bool {
return allowedMethodsOnlyForAdmin[methodName]
}

185
api/v2/shortcut_service.go Normal file
View File

@ -0,0 +1,185 @@
package v2
import (
"context"
"github.com/pkg/errors"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/encoding/protojson"
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
storepb "github.com/boojack/slash/proto/gen/store"
"github.com/boojack/slash/store"
)
type ShortcutService struct {
apiv2pb.UnimplementedShortcutServiceServer
Secret string
Store *store.Store
}
// NewShortcutService creates a new Shortcut service.
func NewShortcutService(secret string, store *store.Store) *ShortcutService {
return &ShortcutService{
Secret: secret,
Store: store,
}
}
func (s *ShortcutService) ListShortcuts(ctx context.Context, _ *apiv2pb.ListShortcutsRequest) (*apiv2pb.ListShortcutsResponse, error) {
userID := ctx.Value(userIDContextKey).(int32)
find := &store.FindShortcut{}
find.VisibilityList = []store.Visibility{store.VisibilityWorkspace, store.VisibilityPublic}
visibleShortcutList, err := s.Store.ListShortcuts(ctx, find)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to fetch visible shortcut list, err: %v", err)
}
find.VisibilityList = []store.Visibility{store.VisibilityPrivate}
find.CreatorID = &userID
shortcutList, err := s.Store.ListShortcuts(ctx, find)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to fetch private shortcut list, err: %v", err)
}
shortcutList = append(shortcutList, visibleShortcutList...)
shortcuts := []*apiv2pb.Shortcut{}
for _, shortcut := range shortcutList {
shortcuts = append(shortcuts, convertShortcutFromStorepb(shortcut))
}
response := &apiv2pb.ListShortcutsResponse{
Shortcuts: shortcuts,
}
return response, nil
}
func (s *ShortcutService) GetShortcut(ctx context.Context, request *apiv2pb.GetShortcutRequest) (*apiv2pb.GetShortcutResponse, error) {
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
Name: &request.Name,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get shortcut by name: %v", err)
}
if shortcut == nil {
return nil, status.Errorf(codes.NotFound, "shortcut not found")
}
userID := ctx.Value(userIDContextKey).(int32)
if shortcut.Visibility == storepb.Visibility_PRIVATE && shortcut.CreatorId != userID {
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
}
shortcutMessage := convertShortcutFromStorepb(shortcut)
response := &apiv2pb.GetShortcutResponse{
Shortcut: shortcutMessage,
}
return response, nil
}
func (s *ShortcutService) CreateShortcut(ctx context.Context, request *apiv2pb.CreateShortcutRequest) (*apiv2pb.CreateShortcutResponse, error) {
userID := ctx.Value(userIDContextKey).(int32)
shortcut := &storepb.Shortcut{
CreatorId: userID,
Name: request.Shortcut.Name,
Link: request.Shortcut.Link,
Title: request.Shortcut.Title,
Tags: request.Shortcut.Tags,
Description: request.Shortcut.Description,
Visibility: storepb.Visibility(request.Shortcut.Visibility),
OgMetadata: &storepb.OpenGraphMetadata{},
}
if request.Shortcut.OgMetadata != nil {
shortcut.OgMetadata = &storepb.OpenGraphMetadata{
Title: request.Shortcut.OgMetadata.Title,
Description: request.Shortcut.OgMetadata.Description,
Image: request.Shortcut.OgMetadata.Image,
}
}
shortcut, err := s.Store.CreateShortcut(ctx, shortcut)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to create shortcut, err: %v", err)
}
if err := s.createShortcutCreateActivity(ctx, shortcut); err != nil {
return nil, status.Errorf(codes.Internal, "failed to create activity, err: %v", err)
}
response := &apiv2pb.CreateShortcutResponse{
Shortcut: convertShortcutFromStorepb(shortcut),
}
return response, nil
}
func (s *ShortcutService) DeleteShortcut(ctx context.Context, request *apiv2pb.DeleteShortcutRequest) (*apiv2pb.DeleteShortcutResponse, 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)
}
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
Name: &request.Name,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get shortcut by name: %v", err)
}
if shortcut == nil {
return nil, status.Errorf(codes.NotFound, "shortcut not found")
}
if shortcut.CreatorId != userID && currentUser.Role != store.RoleAdmin {
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
}
err = s.Store.DeleteShortcut(ctx, &store.DeleteShortcut{
ID: shortcut.Id,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to delete shortcut, err: %v", err)
}
response := &apiv2pb.DeleteShortcutResponse{}
return response, nil
}
func (s *ShortcutService) createShortcutCreateActivity(ctx context.Context, shortcut *storepb.Shortcut) error {
payload := &storepb.ActivityShorcutCreatePayload{
ShortcutId: shortcut.Id,
}
payloadStr, err := protojson.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
}
func convertShortcutFromStorepb(shortcut *storepb.Shortcut) *apiv2pb.Shortcut {
return &apiv2pb.Shortcut{
Id: shortcut.Id,
CreatorId: shortcut.CreatorId,
CreatedTs: shortcut.CreatedTs,
UpdatedTs: shortcut.UpdatedTs,
RowStatus: apiv2pb.RowStatus(shortcut.RowStatus),
Name: shortcut.Name,
Link: shortcut.Link,
Title: shortcut.Title,
Tags: shortcut.Tags,
Description: shortcut.Description,
Visibility: apiv2pb.Visibility(shortcut.Visibility),
OgMetadata: &apiv2pb.OpenGraphMetadata{
Title: shortcut.OgMetadata.Title,
Description: shortcut.OgMetadata.Description,
Image: shortcut.OgMetadata.Image,
},
}
}

View File

@ -0,0 +1,50 @@
package v2
import (
"context"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
"github.com/boojack/slash/server/profile"
"github.com/boojack/slash/server/service/license"
"github.com/boojack/slash/store"
)
type SubscriptionService struct {
apiv2pb.UnimplementedSubscriptionServiceServer
Profile *profile.Profile
Store *store.Store
LicenseService *license.LicenseService
}
// NewSubscriptionService creates a new SubscriptionService.
func NewSubscriptionService(profile *profile.Profile, store *store.Store, licenseService *license.LicenseService) *SubscriptionService {
return &SubscriptionService{
Profile: profile,
Store: store,
LicenseService: licenseService,
}
}
func (s *SubscriptionService) 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 *SubscriptionService) 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
}

View File

@ -2,32 +2,62 @@ package v2
import ( import (
"context" "context"
"time"
apiv2pb "github.com/boojack/slash/proto/gen/api/v2" "github.com/golang-jwt/jwt/v4"
"github.com/boojack/slash/store" "github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
"golang.org/x/exp/slices"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/boojack/slash/api/auth"
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
storepb "github.com/boojack/slash/proto/gen/store"
"github.com/boojack/slash/server/service/license"
"github.com/boojack/slash/store"
) )
type UserService struct { type UserService struct {
apiv2pb.UnimplementedUserServiceServer apiv2pb.UnimplementedUserServiceServer
Store *store.Store Secret string
Store *store.Store
LicenseService *license.LicenseService
} }
// NewUserService creates a new UserService. // NewUserService creates a new UserService.
func NewUserService(store *store.Store) *UserService { func NewUserService(secret string, store *store.Store, licenseService *license.LicenseService) *UserService {
return &UserService{ return &UserService{
Store: store, Secret: secret,
Store: store,
LicenseService: licenseService,
} }
} }
func (s *UserService) ListUsers(ctx context.Context, _ *apiv2pb.ListUsersRequest) (*apiv2pb.ListUsersResponse, error) {
users, err := s.Store.ListUsers(ctx, &store.FindUser{})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list users: %v", err)
}
userMessages := []*apiv2pb.User{}
for _, user := range users {
userMessages = append(userMessages, convertUserFromStore(user))
}
response := &apiv2pb.ListUsersResponse{
Users: userMessages,
}
return response, nil
}
func (s *UserService) GetUser(ctx context.Context, request *apiv2pb.GetUserRequest) (*apiv2pb.GetUserResponse, error) { func (s *UserService) GetUser(ctx context.Context, request *apiv2pb.GetUserRequest) (*apiv2pb.GetUserResponse, error) {
user, err := s.Store.GetUser(ctx, &store.FindUser{ user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &request.Id, ID: &request.Id,
}) })
if err != nil { if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list tags: %v", err) return nil, status.Errorf(codes.Internal, "failed to find user: %v", err)
} }
if user == nil { if user == nil {
return nil, status.Errorf(codes.NotFound, "user not found") return nil, status.Errorf(codes.NotFound, "user not found")
@ -40,6 +70,248 @@ func (s *UserService) GetUser(ctx context.Context, request *apiv2pb.GetUserReque
return response, nil return response, nil
} }
func (s *UserService) CreateUser(ctx context.Context, request *apiv2pb.CreateUserRequest) (*apiv2pb.CreateUserResponse, error) {
passwordHash, err := bcrypt.GenerateFromPassword([]byte(request.User.Password), bcrypt.DefaultCost)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to hash password: %v", err)
}
if !s.LicenseService.IsFeatureEnabled(license.FeatureTypeUnlimitedAccounts) {
userList, err := s.Store.ListUsers(ctx, &store.FindUser{})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list users: %v", err)
}
if len(userList) >= 5 {
return nil, status.Errorf(codes.ResourceExhausted, "maximum number of users reached")
}
}
user, err := s.Store.CreateUser(ctx, &store.User{
Email: request.User.Email,
Nickname: request.User.Nickname,
Role: store.RoleUser,
PasswordHash: string(passwordHash),
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to create user: %v", err)
}
response := &apiv2pb.CreateUserResponse{
User: convertUserFromStore(user),
}
return response, nil
}
func (s *UserService) UpdateUser(ctx context.Context, request *apiv2pb.UpdateUserRequest) (*apiv2pb.UpdateUserResponse, error) {
userID := ctx.Value(userIDContextKey).(int32)
if userID != request.User.Id {
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
}
if request.UpdateMask == nil || len(request.UpdateMask) == 0 {
return nil, status.Errorf(codes.InvalidArgument, "UpdateMask is empty")
}
userUpdate := &store.UpdateUser{
ID: request.User.Id,
}
for _, path := range request.UpdateMask {
if path == "email" {
userUpdate.Email = &request.User.Email
} else if path == "nickname" {
userUpdate.Nickname = &request.User.Nickname
}
}
user, err := s.Store.UpdateUser(ctx, userUpdate)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to update user: %v", err)
}
return &apiv2pb.UpdateUserResponse{
User: convertUserFromStore(user),
}, nil
}
func (s *UserService) DeleteUser(ctx context.Context, request *apiv2pb.DeleteUserRequest) (*apiv2pb.DeleteUserResponse, error) {
userID := ctx.Value(userIDContextKey).(int32)
if userID == request.Id {
return nil, status.Errorf(codes.InvalidArgument, "cannot delete yourself")
}
err := s.Store.DeleteUser(ctx, &store.DeleteUser{
ID: request.Id,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to delete user: %v", err)
}
response := &apiv2pb.DeleteUserResponse{}
return response, nil
}
func (s *UserService) ListUserAccessTokens(ctx context.Context, request *apiv2pb.ListUserAccessTokensRequest) (*apiv2pb.ListUserAccessTokensResponse, error) {
userID := ctx.Value(userIDContextKey).(int32)
if userID != request.Id {
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
}
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, userID)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list access tokens: %v", err)
}
accessTokens := []*apiv2pb.UserAccessToken{}
for _, userAccessToken := range userAccessTokens {
claims := &auth.ClaimsMessage{}
_, err := jwt.ParseWithClaims(userAccessToken.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(s.Secret), nil
}
}
return nil, errors.Errorf("unexpected access token kid=%v", t.Header["kid"])
})
if err != nil {
// If the access token is invalid or expired, just ignore it.
continue
}
userAccessToken := &apiv2pb.UserAccessToken{
AccessToken: userAccessToken.AccessToken,
Description: userAccessToken.Description,
IssuedAt: timestamppb.New(claims.IssuedAt.Time),
}
if claims.ExpiresAt != nil {
userAccessToken.ExpiresAt = timestamppb.New(claims.ExpiresAt.Time)
}
accessTokens = append(accessTokens, userAccessToken)
}
// Sort by issued time in descending order.
slices.SortFunc(accessTokens, func(i, j *apiv2pb.UserAccessToken) bool {
return i.IssuedAt.Seconds > j.IssuedAt.Seconds
})
response := &apiv2pb.ListUserAccessTokensResponse{
AccessTokens: accessTokens,
}
return response, nil
}
func (s *UserService) CreateUserAccessToken(ctx context.Context, request *apiv2pb.CreateUserAccessTokenRequest) (*apiv2pb.CreateUserAccessTokenResponse, error) {
userID := ctx.Value(userIDContextKey).(int32)
if userID != request.Id {
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
}
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 == nil {
return nil, status.Errorf(codes.NotFound, "user not found")
}
expiresAt := time.Time{}
if request.ExpiresAt != nil {
expiresAt = request.ExpiresAt.AsTime()
}
accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, expiresAt, []byte(s.Secret))
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to generate access token: %v", err)
}
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(s.Secret), nil
}
}
return nil, errors.Errorf("unexpected access token kid=%v", t.Header["kid"])
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to parse access token: %v", err)
}
// Upsert the access token to user setting store.
if err := s.UpsertAccessTokenToStore(ctx, user, accessToken, request.Description); err != nil {
return nil, status.Errorf(codes.Internal, "failed to upsert access token to store: %v", err)
}
userAccessToken := &apiv2pb.UserAccessToken{
AccessToken: accessToken,
Description: request.Description,
IssuedAt: timestamppb.New(claims.IssuedAt.Time),
}
if claims.ExpiresAt != nil {
userAccessToken.ExpiresAt = timestamppb.New(claims.ExpiresAt.Time)
}
response := &apiv2pb.CreateUserAccessTokenResponse{
AccessToken: userAccessToken,
}
return response, nil
}
func (s *UserService) DeleteUserAccessToken(ctx context.Context, request *apiv2pb.DeleteUserAccessTokenRequest) (*apiv2pb.DeleteUserAccessTokenResponse, error) {
userID := ctx.Value(userIDContextKey).(int32)
if userID != request.Id {
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
}
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, userID)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list access tokens: %v", err)
}
updatedUserAccessTokens := []*storepb.AccessTokensUserSetting_AccessToken{}
for _, userAccessToken := range userAccessTokens {
if userAccessToken.AccessToken == request.AccessToken {
continue
}
updatedUserAccessTokens = append(updatedUserAccessTokens, 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: updatedUserAccessTokens,
},
},
}); err != nil {
return nil, status.Errorf(codes.Internal, "failed to upsert user setting: %v", err)
}
return &apiv2pb.DeleteUserAccessTokenResponse{}, nil
}
func (s *UserService) UpsertAccessTokenToStore(ctx context.Context, user *store.User, accessToken, description 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: description,
}
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 errors.Wrap(err, "failed to upsert user setting")
}
return nil
}
func convertUserFromStore(user *store.User) *apiv2pb.User { func convertUserFromStore(user *store.User) *apiv2pb.User {
return &apiv2pb.User{ return &apiv2pb.User{
Id: int32(user.ID), Id: int32(user.ID),

View File

@ -0,0 +1,148 @@
package v2
import (
"context"
"github.com/pkg/errors"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
storepb "github.com/boojack/slash/proto/gen/store"
"github.com/boojack/slash/store"
)
type UserSettingService struct {
apiv2pb.UnimplementedUserSettingServiceServer
Store *store.Store
}
// NewUserSettingService creates a new UserSettingService.
func NewUserSettingService(store *store.Store) *UserSettingService {
return &UserSettingService{
Store: store,
}
}
func (s *UserSettingService) 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 *UserSettingService) UpdateUserSetting(ctx context.Context, request *apiv2pb.UpdateUserSettingRequest) (*apiv2pb.UpdateUserSettingResponse, error) {
if request.UpdateMask == nil || len(request.UpdateMask) == 0 {
return nil, status.Errorf(codes.InvalidArgument, "update mask is empty")
}
userID := ctx.Value(userIDContextKey).(int32)
for _, path := range request.UpdateMask {
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
}
}

View File

@ -4,37 +4,48 @@ import (
"context" "context"
"fmt" "fmt"
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
"github.com/boojack/slash/server/profile"
"github.com/boojack/slash/store"
grpcRuntime "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" grpcRuntime "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"github.com/improbable-eng/grpc-web/go/grpcweb"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/reflection"
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
"github.com/boojack/slash/server/profile"
"github.com/boojack/slash/server/service/license"
"github.com/boojack/slash/store"
) )
type APIV2Service struct { type APIV2Service struct {
Secret string Secret string
Profile *profile.Profile Profile *profile.Profile
Store *store.Store Store *store.Store
LicenseService *license.LicenseService
grpcServer *grpc.Server grpcServer *grpc.Server
grpcServerPort int grpcServerPort int
} }
func NewAPIV2Service(secret string, profile *profile.Profile, store *store.Store, grpcServerPort int) *APIV2Service { func NewAPIV2Service(secret string, profile *profile.Profile, store *store.Store, licenseService *license.LicenseService, grpcServerPort int) *APIV2Service {
authProvider := NewGRPCAuthInterceptor(store, secret) authProvider := NewGRPCAuthInterceptor(store, secret)
grpcServer := grpc.NewServer( grpcServer := grpc.NewServer(
grpc.ChainUnaryInterceptor( grpc.ChainUnaryInterceptor(
authProvider.AuthenticationInterceptor, authProvider.AuthenticationInterceptor,
), ),
) )
apiv2pb.RegisterUserServiceServer(grpcServer, NewUserService(store)) apiv2pb.RegisterSubscriptionServiceServer(grpcServer, NewSubscriptionService(profile, store, licenseService))
apiv2pb.RegisterWorkspaceServiceServer(grpcServer, NewWorkspaceService(profile, store, licenseService))
apiv2pb.RegisterUserServiceServer(grpcServer, NewUserService(secret, store, licenseService))
apiv2pb.RegisterUserSettingServiceServer(grpcServer, NewUserSettingService(store))
apiv2pb.RegisterShortcutServiceServer(grpcServer, NewShortcutService(secret, store))
reflection.Register(grpcServer)
return &APIV2Service{ return &APIV2Service{
Secret: secret, Secret: secret,
Profile: profile, Profile: profile,
Store: store, Store: store,
LicenseService: licenseService,
grpcServer: grpcServer, grpcServer: grpcServer,
grpcServerPort: grpcServerPort, grpcServerPort: grpcServerPort,
} }
@ -58,10 +69,32 @@ func (s *APIV2Service) RegisterGateway(ctx context.Context, e *echo.Echo) error
} }
gwMux := grpcRuntime.NewServeMux() gwMux := grpcRuntime.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.RegisterUserServiceHandler(context.Background(), gwMux, conn); err != nil { if err := apiv2pb.RegisterUserServiceHandler(context.Background(), gwMux, conn); err != nil {
return err 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
}
e.Any("/api/v2/*", echo.WrapHandler(gwMux)) 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 return nil
} }

157
api/v2/workspace_service.go Normal file
View File

@ -0,0 +1,157 @@
package v2
import (
"context"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
storepb "github.com/boojack/slash/proto/gen/store"
"github.com/boojack/slash/server/profile"
"github.com/boojack/slash/server/service/license"
"github.com/boojack/slash/store"
)
type WorkspaceService struct {
apiv2pb.UnimplementedWorkspaceServiceServer
Profile *profile.Profile
Store *store.Store
LicenseService *license.LicenseService
}
// NewWorkspaceService creates a new WorkspaceService.
func NewWorkspaceService(profile *profile.Profile, store *store.Store, licenseService *license.LicenseService) *WorkspaceService {
return &WorkspaceService{
Profile: profile,
Store: store,
LicenseService: licenseService,
}
}
func (s *WorkspaceService) GetWorkspaceProfile(ctx context.Context, _ *apiv2pb.GetWorkspaceProfileRequest) (*apiv2pb.GetWorkspaceProfileResponse, error) {
profile := &apiv2pb.WorkspaceProfile{
Mode: s.Profile.Mode,
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 *WorkspaceService) 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_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 *WorkspaceService) UpdateWorkspaceSetting(ctx context.Context, request *apiv2pb.UpdateWorkspaceSettingRequest) (*apiv2pb.UpdateWorkspaceSettingResponse, error) {
if request.UpdateMask == nil || len(request.UpdateMask) == 0 {
return nil, status.Errorf(codes.InvalidArgument, "update mask is empty")
}
for _, path := range request.UpdateMask {
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 == "custom_style" {
if !s.LicenseService.IsFeatureEnabled(license.FeatureTypeCustomeStyle) {
return nil, status.Errorf(codes.PermissionDenied, "feature custom style is not available")
}
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
}

View File

@ -10,10 +10,12 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper" "github.com/spf13/viper"
"go.uber.org/zap"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
"github.com/boojack/slash/internal/log"
"github.com/boojack/slash/server" "github.com/boojack/slash/server"
_profile "github.com/boojack/slash/server/profile" "github.com/boojack/slash/server/profile"
"github.com/boojack/slash/store" "github.com/boojack/slash/store"
"github.com/boojack/slash/store/db" "github.com/boojack/slash/store/db"
) )
@ -23,28 +25,28 @@ const (
) )
var ( var (
profile *_profile.Profile serverProfile *profile.Profile
mode string mode string
port int port int
data string data string
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 bookmarks and link sharing platform.`,
Run: func(_cmd *cobra.Command, _args []string) { Run: func(_cmd *cobra.Command, _args []string) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
db := db.NewDB(profile) db := db.NewDB(serverProfile)
if err := db.Open(ctx); err != nil { if err := db.Open(ctx); err != nil {
cancel() cancel()
fmt.Printf("failed to open db, error: %+v\n", err) log.Error("failed to open database", zap.Error(err))
return return
} }
storeInstance := store.New(db.DBInstance, profile) storeInstance := store.New(db.DBInstance, serverProfile)
s, err := server.NewServer(ctx, profile, storeInstance) s, err := server.NewServer(ctx, serverProfile, storeInstance)
if err != nil { if err != nil {
cancel() cancel()
fmt.Printf("failed to create server, error: %+v\n", err) log.Error("failed to create server", zap.Error(err))
return return
} }
@ -55,16 +57,16 @@ var (
signal.Notify(c, os.Interrupt, syscall.SIGTERM) signal.Notify(c, os.Interrupt, syscall.SIGTERM)
go func() { go func() {
sig := <-c sig := <-c
fmt.Printf("%s received.\n", sig.String()) log.Info(fmt.Sprintf("%s received.\n", sig.String()))
s.Shutdown(ctx) s.Shutdown(ctx)
cancel() cancel()
}() }()
println(greetingBanner) printGreetings()
fmt.Printf("Version %s has started at :%d\n", profile.Version, profile.Port)
if err := s.Start(ctx); err != nil { if err := s.Start(ctx); err != nil {
if err != http.ErrServerClosed { if err != http.ErrServerClosed {
fmt.Printf("failed to start server, error: %+v\n", err) log.Error("failed to start server", zap.Error(err))
cancel() cancel()
} }
} }
@ -76,6 +78,7 @@ var (
) )
func Execute() error { func Execute() error {
defer log.Sync()
return rootCmd.Execute() return rootCmd.Execute()
} }
@ -107,18 +110,27 @@ func init() {
func initConfig() { func initConfig() {
viper.AutomaticEnv() viper.AutomaticEnv()
var err error var err error
profile, err = _profile.GetProfile() serverProfile, err = profile.GetProfile()
if err != nil { if err != nil {
fmt.Printf("failed to get profile, error: %+v\n", err) log.Error("failed to get profile", zap.Error(err))
return return
} }
println("---") println("---")
println("Server profile") println("Server profile")
println("dsn:", profile.DSN) println("dsn:", serverProfile.DSN)
println("port:", profile.Port) println("port:", serverProfile.Port)
println("mode:", profile.Mode) println("mode:", serverProfile.Mode)
println("version:", profile.Version) println("version:", serverProfile.Version)
println("---")
}
func printGreetings() {
println(greetingBanner)
fmt.Printf("Version %s has been started on port %d\n", serverProfile.Version, serverProfile.Port)
println("---")
println("See more in:")
fmt.Printf("👉GitHub: %s\n", "https://github.com/boojack/slash")
println("---") println("---")
} }

13
docker-compose.yml Normal file
View File

@ -0,0 +1,13 @@
version: '3'
services:
slash:
image: yourselfhosted/slash:latest
container_name: slash
ports:
- 5231:5231
volumes:
- slash:/var/opt/slash
restart: unless-stopped
volumes:
slash:

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@ -0,0 +1,45 @@
# The Browser Extension of Slash
Slash provides a browser extension to help you use your shortcuts in the search bar to go to the corresponding URL.
## How to use
### Install the extension
For Chromuim based browsers, you can install the extension from the [Chrome Web Store](https://chrome.google.com/webstore/detail/slash/ebaiehmkammnacjadffpicipfckgeobg).
For Firefox, we don't support the Firefox Add-ons platform yet. And we are working on it.
### Generate an access token
1. Go to your Slash instance and sign in with your account.
2. Go to the settings page and click on the "Create" button to create an access token.
![](./assets/extension-usage/create-access-token.png)
3. Copy the access token and save it somewhere safe.
![](./assets/extension-usage/copy-access-token.png)
### Configure the extension
1. Click on the extension icon and click on the "Settings" button.
![](./assets/extension-usage/extension-setting-button.png)
2. Enter your Slash's domain and paste the access token you generated in the previous step.
![](./assets/extension-usage/extension-setting-page.png)
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.
![](./assets/extension-usage/extension-screenshot.png)
### 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).
![](./assets/extension-usage/shortcut-url.png)

View File

@ -16,7 +16,7 @@ docker run -d --name slash --publish 5231:5231 --volume ~/.slash/:/var/opt/slash
This will start Slash in the background and expose it on port `5231`. Data is stored in `~/.slash/`. You can customize the port and data directory. This will start Slash in the background and expose it on port `5231`. Data is stored in `~/.slash/`. You can customize the port and data directory.
## Upgrade ### Upgrade
To upgrade Slash to latest version, stop and remove the old container first: To upgrade Slash to latest version, stop and remove the old container first:
@ -37,3 +37,23 @@ docker pull yourselfhosted/slash:latest
``` ```
Finally, restart Slash by following the steps in [Docker Run](#docker-run). Finally, restart Slash by following the steps in [Docker Run](#docker-run).
## Docker Compose Run
Assume that docker compose is deployed in the `/opt/slash` directory.
```bash
mkdir -p /opt/slash && cd /opt/slash
curl -#LO https://github.com/boojack/slash/raw/main/docker-compose.yml
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.
### Upgrade
```bash
cd /opt/slash
docker compose pull
docker compose up -d
```

40
frontend/extension/.gitignore vendored Normal file
View File

@ -0,0 +1,40 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
#cache
.turbo
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*
out/
build/
dist/
.plasmo
# bpp - http://bpp.browser.market/
keys.json
# typescript
.tsbuildinfo
src/types/proto

View File

@ -0,0 +1,8 @@
module.exports = {
printWidth: 140,
useTabs: false,
semi: true,
singleQuote: false,
plugins: [require.resolve("@trivago/prettier-plugin-sort-imports")],
importOrder: ["<BUILTIN_MODULES>", "<THIRD_PARTY_MODULES>", "^@/((?!css).+)", "^[./]", "^[../]", "^(.+).css"],
};

View File

@ -0,0 +1 @@
# Slash Browser Extension

View File

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

View File

@ -0,0 +1,58 @@
{
"name": "slash-extension",
"displayName": "Slash",
"version": "1.0.0",
"description": "An open source, self-hosted bookmarks and link sharing platform. Save and share your links very easily.",
"scripts": {
"dev": "plasmo dev",
"build": "plasmo build",
"package": "plasmo package",
"lint": "eslint --ext .js,.ts,.tsx, src",
"lint-fix": "eslint --ext .js,.ts,.tsx, src --fix"
},
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@mui/joy": "5.0.0-beta.0",
"@plasmohq/storage": "^1.8.0",
"axios": "^1.5.1",
"classnames": "^2.3.2",
"lodash-es": "^4.17.21",
"lucide-react": "^0.264.0",
"plasmo": "0.82.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hot-toast": "^2.4.1",
"zustand": "^4.4.1"
},
"devDependencies": {
"@trivago/prettier-plugin-sort-imports": "4.1.0",
"@types/chrome": "0.0.241",
"@types/lodash-es": "^4.17.9",
"@types/node": "20.4.2",
"@types/react": "18.2.15",
"@types/react-dom": "18.2.7",
"@typescript-eslint/eslint-plugin": "^6.7.3",
"@typescript-eslint/parser": "^6.7.3",
"autoprefixer": "^10.4.16",
"eslint": "^8.50.0",
"eslint-config-prettier": "^8.10.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.33.2",
"long": "^5.2.3",
"postcss": "^8.4.31",
"prettier": "2.6.2",
"protobufjs": "^7.2.5",
"tailwindcss": "^3.3.3",
"typescript": "5.1.6"
},
"manifest": {
"omnibox": {
"keyword": "s"
},
"permissions": [
"tabs",
"storage"
]
}
}

7380
frontend/extension/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,10 @@
/* eslint-disable no-undef */
/**
* @type {import('postcss').ProcessOptions}
*/
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -0,0 +1,62 @@
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
import { Storage } from "@plasmohq/storage";
const storage = new Storage();
const urlRegex = /https?:\/\/s\/(.+)/;
chrome.tabs.onUpdated.addListener(async (tabId, _, tab) => {
if (!tab.url) {
return;
}
const shortcutName = getShortcutNameFromUrl(tab.url);
if (shortcutName) {
const shortcuts = (await storage.getItem<Shortcut[]>("shortcuts")) || [];
const shortcut = shortcuts.find((shortcut) => shortcut.name === shortcutName);
if (!shortcut) {
return;
}
return chrome.tabs.update(tabId, { url: shortcut.link });
}
});
chrome.omnibox.onInputEntered.addListener(async (text) => {
const shortcuts = (await storage.getItem<Shortcut[]>("shortcuts")) || [];
const shortcut = shortcuts.find((shortcut) => shortcut.name === text);
if (!shortcut) {
return;
}
return chrome.tabs.update({ url: shortcut.link });
});
const getShortcutNameFromUrl = (urlString: string) => {
const matchResult = urlRegex.exec(urlString);
if (matchResult === null) {
return getShortcutNameFromSearchUrl(urlString);
}
return matchResult[1];
};
const getShortcutNameFromSearchUrl = (urlString: string) => {
const url = new URL(urlString);
if ((url.hostname === "www.google.com" || url.hostname === "www.bing.com") && url.pathname === "/search") {
const params = new URLSearchParams(url.search);
const shortcutName = params.get("q");
if (typeof shortcutName === "string" && shortcutName.startsWith("s/")) {
return shortcutName.slice(2);
}
} else if (url.hostname === "www.baidu.com" && url.pathname === "/s") {
const params = new URLSearchParams(url.search);
const shortcutName = params.get("wd");
if (typeof shortcutName === "string" && shortcutName.startsWith("s/")) {
return shortcutName.slice(2);
}
} else if (url.hostname === "duckduckgo.com" && url.pathname === "/") {
const params = new URLSearchParams(url.search);
const shortcutName = params.get("q");
if (typeof shortcutName === "string" && shortcutName.startsWith("s/")) {
return shortcutName.slice(2);
}
}
return "";
};

View File

@ -0,0 +1,173 @@
import { Button, IconButton, Input, Modal, ModalDialog } from "@mui/joy";
import { useStorage } from "@plasmohq/storage/hook";
import axios from "axios";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { CreateShortcutResponse, OpenGraphMetadata, Visibility } from "@/types/proto/api/v2/shortcut_service";
import Icon from "./Icon";
const generateTempName = (length = 6) => {
let result = "";
const characters = "abcdefghijklmnopqrstuvwxyz0123456789";
const charactersLength = characters.length;
let counter = 0;
while (counter < length) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
counter += 1;
}
return result;
};
interface State {
name: string;
title: string;
link: string;
}
const CreateShortcutsButton = () => {
const [domain] = useStorage("domain");
const [accessToken] = useStorage("access_token");
const [shortcuts, setShortcuts] = useStorage("shortcuts");
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: generateTempName() + "-temp",
title: tab.title || "",
link: tab.url || "",
}));
setShowModal(true);
});
};
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 {
const {
data: { shortcut },
} = await axios.post<CreateShortcutResponse>(
`${domain}/api/v2/shortcuts`,
{
name: state.name,
title: state.title,
link: state.link,
visibility: Visibility.PRIVATE,
ogMetadata: OpenGraphMetadata.fromPartial({}),
},
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);
setShortcuts([shortcut, ...shortcuts]);
toast.success("Shortcut created successfully");
setShowModal(false);
} catch (error: any) {
console.error(error);
toast.error(error.response.data.message);
}
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} />
</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="https://github.com/boojack/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 CreateShortcutsButton;

View File

@ -0,0 +1,12 @@
import classNames from "classnames";
import LogoBase64 from "data-base64:../..//assets/icon.png";
interface Props {
className?: string;
}
const Logo = ({ className }: Props) => {
return <img className={classNames(className)} src={LogoBase64} alt="" />;
};
export default Logo;

View File

@ -0,0 +1,45 @@
import { IconButton } from "@mui/joy";
import { useStorage } from "@plasmohq/storage/hook";
import axios from "axios";
import { useEffect } from "react";
import { toast } from "react-hot-toast";
import { ListShortcutsResponse } from "@/types/proto/api/v2/shortcut_service";
import Icon from "./Icon";
const PullShortcutsButton = () => {
const [domain] = useStorage("domain");
const [accessToken] = useStorage("access_token");
const [, setShortcuts] = useStorage("shortcuts");
useEffect(() => {
if (domain && accessToken) {
handlePullShortcuts(true);
}
}, [domain, accessToken]);
const handlePullShortcuts = async (silence = false) => {
try {
const {
data: { shortcuts },
} = await axios.get<ListShortcutsResponse>(`${domain}/api/v2/shortcuts`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
setShortcuts(shortcuts);
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;

View File

@ -0,0 +1,67 @@
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
import { useStorage } from "@plasmohq/storage/hook";
import classNames from "classnames";
import { getFaviconWithGoogleS2 } from "@/helpers/utils";
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-full 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-full" 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">(s/{shortcut.name})</span>
) : (
<>
<span className="text-gray-400 dark:text-gray-500">s/</span>
<span className="truncate dark:text-gray-400">{shortcut.name}</span>
</>
)}
</div>
<span className="hidden group-hover:block ml-1 cursor-pointer shrink-0">
<Icon.ExternalLink className="w-4 h-auto text-gray-600" />
</span>
</button>
</div>
</div>
</div>
</div>
</>
);
};
export default ShortcutView;

View File

@ -0,0 +1,18 @@
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
import { useStorage } from "@plasmohq/storage/hook";
import classNames from "classnames";
import ShortcutView from "./ShortcutView";
const ShortcutsContainer = () => {
const [shortcuts] = useStorage<Shortcut[]>("shortcuts", (v) => (v ? v : []));
return (
<div className={classNames("w-full grid grid-cols-2 gap-2")}>
{shortcuts.map((shortcut) => {
return <ShortcutView key={shortcut.id} shortcut={shortcut} />;
})}
</div>
);
};
export default ShortcutsContainer;

View File

@ -0,0 +1,14 @@
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;
}
};

View File

@ -0,0 +1,43 @@
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;

View File

@ -0,0 +1,179 @@
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
import { Button, CssVarsProvider, Divider, Input, Select, Option } from "@mui/joy";
import { useStorage } from "@plasmohq/storage/hook";
import { useEffect, useState } from "react";
import { Toaster, toast } from "react-hot-toast";
import Icon from "./components/Icon";
import Logo from "./components/Logo";
import PullShortcutsButton from "./components/PullShortcutsButton";
import ShortcutsContainer from "./components/ShortcutsContainer";
import useColorTheme from "./hooks/useColorTheme";
import "./style.css";
interface SettingState {
domain: string;
accessToken: string;
}
const colorThemeOptions = [
{
value: "system",
label: "System",
},
{
value: "light",
label: "Light",
},
{
value: "dark",
label: "Dark",
},
];
const IndexOptions = () => {
const { colorTheme, setColorTheme } = useColorTheme();
const [domain, setDomain] = useStorage<string>("domain", (v) => (v ? v : ""));
const [accessToken, setAccessToken] = useStorage<string>("access_token", (v) => (v ? v : ""));
const [settingState, setSettingState] = useState<SettingState>({
domain,
accessToken,
});
const [shortcuts] = useStorage<Shortcut[]>("shortcuts", []);
const isInitialized = domain && accessToken;
useEffect(() => {
setSettingState({
domain,
accessToken,
});
}, [domain, accessToken]);
const setPartialSettingState = (partialSettingState: Partial<SettingState>) => {
setSettingState((prevState) => ({
...prevState,
...partialSettingState,
}));
};
const handleSaveSetting = () => {
setDomain(settingState.domain);
setAccessToken(settingState.accessToken);
toast.success("Setting saved");
};
const handleSelectColorTheme = async (colorTheme: string) => {
setColorTheme(colorTheme as any);
};
return (
<div className="w-full">
<div className="w-full flex flex-row justify-center items-center">
<a
className="bg-yellow-100 dark:bg-yellow-500 dark:opacity-70 mt-12 py-2 px-3 rounded-full border dark:border-yellow-600 flex flex-row justify-start items-center cursor-pointer shadow hover:underline hover:text-blue-600"
href="https://github.com/boojack/slash#browser-extension"
target="_blank"
>
<Icon.HelpCircle className="w-4 h-auto" />
<span className="mx-1 text-sm">Need help? Check out the docs</span>
<Icon.ExternalLink className="w-4 h-auto" />
</a>
</div>
<div className="w-full max-w-lg mx-auto flex flex-col justify-start items-start mt-12">
<h2 className="flex flex-row justify-start items-center mb-6 text-2xl dark:text-gray-400">
<Logo className="w-10 h-auto mr-2" />
<span>Slash</span>
<span className="mx-2 text-gray-400">/</span>
<span>Setting</span>
</h2>
<div className="w-full flex flex-col justify-start items-start">
<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">
<span className="dark:text-gray-400">Domain</span>
{domain !== "" && (
<a
className="text-sm flex flex-row justify-start items-center dark:text-gray-400 hover:underline hover:text-blue-600"
href={domain}
target="_blank"
>
<span className="mr-1">Go to my Slash</span>
<Icon.ExternalLink className="w-4 h-auto" />
</a>
)}
</div>
<div className="relative w-full">
<Input
className="w-full"
type="text"
placeholder="The domain of your Slash instance"
value={settingState.domain}
onChange={(e) => setPartialSettingState({ domain: 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="The access token of your Slash instance"
value={settingState.accessToken}
onChange={(e) => setPartialSettingState({ accessToken: e.target.value })}
/>
</div>
</div>
<div className="w-full mt-6 flex flex-row justify-end">
<Button onClick={handleSaveSetting}>Save</Button>
</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>
<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>
);
};
const Options = () => {
return (
<CssVarsProvider>
<IndexOptions />
<Toaster position="top-right" />
</CssVarsProvider>
);
};
export default Options;

View File

@ -0,0 +1,110 @@
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
import { Button, CssVarsProvider, Divider, IconButton } from "@mui/joy";
import { useStorage } from "@plasmohq/storage/hook";
import { Toaster } from "react-hot-toast";
import CreateShortcutsButton from "@/components/CreateShortcutsButton";
import Icon from "@/components/Icon";
import Logo from "@/components/Logo";
import PullShortcutsButton from "@/components/PullShortcutsButton";
import ShortcutsContainer from "@/components/ShortcutsContainer";
import useColorTheme from "./hooks/useColorTheme";
import "./style.css";
const IndexPopup = () => {
useColorTheme();
const [domain] = useStorage<string>("domain", "");
const [accessToken] = useStorage<string>("access_token", "");
const [shortcuts] = useStorage<Shortcut[]>("shortcuts", []);
const isInitialized = domain && accessToken;
const handleSettingButtonClick = () => {
chrome.runtime.openOptionsPage();
};
const handleRefreshButtonClick = () => {
chrome.runtime.reload();
chrome.browserAction.setPopup({ popup: "" });
};
return (
<div className="w-full min-w-[512px] px-4 pt-4">
<div className="w-full flex flex-row justify-between items-center">
<div className="flex flex-row justify-start items-center dark:text-gray-400">
<Logo className="w-6 h-auto mr-2" />
<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>{isInitialized && <CreateShortcutsButton />}</div>
</div>
<div className="w-full mt-4">
{isInitialized ? (
<>
{shortcuts.length !== 0 ? (
<ShortcutsContainer />
) : (
<div className="w-full flex flex-col justify-center items-center">
<p>No shortcut found.</p>
</div>
)}
<Divider className="!mt-4 !mb-2 opacity-40" />
<div className="w-full flex flex-row justify-between items-center mb-2">
<div className="flex flex-row justify-start items-center">
<IconButton size="sm" variant="plain" color="neutral" onClick={handleSettingButtonClick}>
<Icon.Settings className="w-5 h-auto text-gray-500 dark:text-gray-400" />
</IconButton>
<IconButton size="sm" variant="plain" color="neutral" component="a" href="https://github.com/boojack/slash" target="_blank">
<Icon.Github className="w-5 h-auto text-gray-500 dark:text-gray-400" />
</IconButton>
</div>
<div className="flex flex-row justify-end items-center">
<a
className="text-sm flex flex-row justify-start items-center text-gray-500 dark:text-gray-400 hover:underline hover:text-blue-600"
href={domain}
target="_blank"
>
<span className="mr-1">Go to my Slash</span>
<Icon.ExternalLink className="w-4 h-auto" />
</a>
</div>
</div>
</>
) : (
<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" />
<p className="dark:text-gray-400">Please set your domain and access token first.</p>
<div className="w-full flex flex-row justify-center items-center py-4">
<Button size="sm" color="primary" onClick={handleSettingButtonClick}>
<Icon.Settings className="w-5 h-auto mr-1" /> Setting
</Button>
<span className="mx-2 dark:text-gray-400">Or</span>
<Button size="sm" variant="outlined" color="neutral" onClick={handleRefreshButtonClick}>
<Icon.RefreshCcw className="w-5 h-auto mr-1" /> Refresh
</Button>
</div>
</div>
)}
</div>
</div>
);
};
const Popup = () => {
return (
<CssVarsProvider>
<IndexPopup />
<Toaster position="top-right" />
</CssVarsProvider>
);
};
export default Popup;

View File

@ -5,7 +5,7 @@
body, body,
html, html,
#root { #root {
@apply text-base w-full h-full; @apply text-base dark:bg-zinc-900;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Noto Sans", "Noto Sans CJK SC", "Microsoft YaHei UI", "Microsoft YaHei", font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Noto Sans", "Noto Sans CJK SC", "Microsoft YaHei UI", "Microsoft YaHei",
"WenQuanYi Micro Hei", sans-serif, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "WenQuanYi Micro Hei", sans-serif, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Noto Color Emoji"; "Noto Color Emoji";

View File

@ -0,0 +1,8 @@
/* eslint-disable no-undef */
/** @type {import('tailwindcss').Config} */
module.exports = {
mode: "jit",
darkMode: "class",
content: ["./**/*.tsx"],
plugins: [],
};

View File

@ -0,0 +1,20 @@
{
"extends": "plasmo/templates/tsconfig.base",
"exclude": [
"node_modules"
],
"include": [
".plasmo/index.d.ts",
"./**/*.ts",
"./**/*.tsx",
"../types"
],
"compilerOptions": {
"paths": {
"@/*": [
"./src/*"
],
},
"baseUrl": "."
}
}

View File

@ -0,0 +1,3 @@
# Translation files
This directory contains the translation files for the frontend including web and browser extension.

82
frontend/locales/en.json Normal file
View File

@ -0,0 +1,82 @@
{
"common": {
"about": "About",
"loading": "Loading",
"cancel": "Cancel",
"save": "Save",
"create": "Create",
"download": "Download",
"edit": "Edit",
"delete": "Delete",
"language": "Language",
"search": "Search",
"email": "Email",
"password": "Password",
"account": "Account"
},
"auth": {
"sign-in": "Sign in",
"sign-up": "Sign up",
"sign-out": "Sign out",
"create-your-account": "Create your account"
},
"analytics": {
"self": "Analytics",
"top-sources": "Top sources",
"source": "Source",
"visitors": "Visitors",
"devices": "Devices",
"browser": "Browser",
"browsers": "Browsers",
"operating-system": "Operating System"
},
"shortcut": {
"visits": "{{count}} visits",
"visibility": {
"private": {
"self": "Private",
"description": "Only you can access"
},
"workspace": {
"self": "Workspace",
"description": "Workspace members can access"
},
"public": {
"self": "Public",
"description": "Visible to everyone on the internet"
}
}
},
"filter": {
"all": "All",
"mine": "Mine",
"compact-mode": "Compact mode",
"order-by": "Order by",
"direction": "Direction"
},
"user": {
"self": "User",
"nickname": "Nickname",
"email": "Email",
"role": "Role",
"profile": "Profile",
"action": {
"add-user": "Add user"
}
},
"settings": {
"self": "Setting",
"preference": {
"self": "Preference",
"color-theme": "Color theme"
},
"workspace": {
"self": "Workspace settings",
"custom-style": "Custom style",
"enable-user-signup": {
"self": "Enable user signup",
"description": "Once enabled, other users can signup."
}
}
}
}

82
frontend/locales/zh.json Normal file
View File

@ -0,0 +1,82 @@
{
"common": {
"about": "关于",
"loading": "加载中",
"cancel": "取消",
"save": "保存",
"create": "创建",
"download": "下载",
"edit": "编辑",
"delete": "删除",
"language": "语言",
"search": "搜索",
"email": "邮箱",
"password": "密码",
"account": "账号"
},
"auth": {
"sign-in": "登录",
"sign-up": "注册",
"sign-out": "退出登录",
"create-your-account": "创建账号"
},
"analytics": {
"self": "分析",
"top-sources": "热门来源",
"source": "来源",
"visitors": "访客数",
"devices": "设备",
"browser": "浏览器",
"browsers": "浏览器",
"operating-system": "操作系统"
},
"shortcut": {
"visits": "{{count}} 次访问",
"visibility": {
"private": {
"self": "私有的",
"description": "仅您可以访问"
},
"workspace": {
"self": "工作区",
"description": "工作区成员可以访问"
},
"public": {
"self": "公开的",
"description": "对任何人可见"
}
}
},
"filter": {
"all": "所有",
"mine": "我的",
"compact-mode": "紧凑模式",
"order-by": "排序方式",
"direction": "方向"
},
"user": {
"self": "用户",
"nickname": "昵称",
"email": "邮箱",
"role": "角色",
"profile": "账号",
"action": {
"add-user": "添加用户"
}
},
"settings": {
"self": "设置",
"preference": {
"self": "偏好设置",
"color-theme": "主题"
},
"workspace": {
"self": "系统设置",
"custom-style": "自定义样式",
"enable-user-signup": {
"self": "启用用户注册",
"description": "允许其他用户注册新账号"
}
}
}
}

View File

@ -0,0 +1,33 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": ["eslint:recommended", "plugin:react/recommended", "plugin:@typescript-eslint/recommended", "plugin:prettier/recommended"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": ["react", "@typescript-eslint", "prettier"],
"ignorePatterns": ["node_modules", "dist", "public"],
"rules": {
"prettier/prettier": [
"error",
{
"endOfLine": "auto"
}
],
"@typescript-eslint/no-explicit-any": ["off"],
"react/react-in-jsx-scope": "off",
"react/jsx-no-target-blank": "off"
},
"settings": {
"react": {
"version": "detect"
}
}
}

View File

@ -3,3 +3,4 @@ node_modules
dist dist
dist-ssr dist-ssr
*.local *.local
src/types/proto

View File

@ -0,0 +1,8 @@
module.exports = {
printWidth: 140,
useTabs: false,
semi: true,
singleQuote: false,
plugins: [require.resolve("@trivago/prettier-plugin-sort-imports")],
importOrder: ["<BUILTIN_MODULES>", "<THIRD_PARTY_MODULES>", "^@/((?!less).+)", "^[./]", "^(.+).less"],
};

54
frontend/web/package.json Normal file
View File

@ -0,0 +1,54 @@
{
"name": "slash",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"serve": "vite preview",
"lint": "eslint --ext .js,.ts,.tsx, src",
"lint-fix": "eslint --ext .js,.ts,.tsx, src --fix"
},
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@mui/joy": "5.0.0-beta.7",
"@reduxjs/toolkit": "^1.9.6",
"axios": "^0.27.2",
"classnames": "^2.3.2",
"copy-to-clipboard": "^3.3.3",
"dayjs": "^1.11.10",
"i18next": "^23.5.1",
"lodash-es": "^4.17.21",
"lucide-react": "^0.263.1",
"nice-grpc-web": "^3.3.1",
"qrcode.react": "^3.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hot-toast": "^2.4.1",
"react-i18next": "^13.2.2",
"react-redux": "^8.1.2",
"react-router-dom": "^6.16.0",
"react-use": "^17.4.0",
"tailwindcss": "^3.3.3",
"zustand": "^4.4.1"
},
"devDependencies": {
"@trivago/prettier-plugin-sort-imports": "^4.2.0",
"@types/lodash-es": "^4.17.9",
"@types/react": "^18.2.23",
"@types/react-dom": "^18.2.8",
"@typescript-eslint/eslint-plugin": "^6.7.3",
"@typescript-eslint/parser": "^6.7.3",
"@vitejs/plugin-react-swc": "^3.4.0",
"autoprefixer": "^10.4.16",
"eslint": "^8.50.0",
"eslint-config-prettier": "^8.10.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.33.2",
"long": "^5.2.3",
"postcss": "^8.4.31",
"prettier": "2.6.2",
"protobufjs": "^7.2.5",
"typescript": "^5.2.2",
"vite": "^4.4.9"
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

75
frontend/web/src/App.tsx Normal file
View File

@ -0,0 +1,75 @@
import { useColorScheme } from "@mui/joy";
import { useEffect, useState } from "react";
import { Outlet } from "react-router-dom";
import DemoBanner from "./components/DemoBanner";
import useUserStore from "./stores/v1/user";
import useWorkspaceStore from "./stores/v1/workspace";
function App() {
const { mode: colorScheme } = useColorScheme();
const userStore = useUserStore();
const workspaceStore = useWorkspaceStore();
const [loading, setLoading] = useState(true);
useEffect(() => {
(async () => {
try {
await Promise.all([workspaceStore.fetchWorkspaceProfile(), workspaceStore.fetchWorkspaceSetting(), userStore.fetchCurrentUser()]);
} catch (error) {
// do nth
}
setLoading(false);
})();
}, []);
useEffect(() => {
const styleEl = document.createElement("style");
styleEl.innerHTML = workspaceStore.setting.customStyle;
styleEl.setAttribute("type", "text/css");
document.body.insertAdjacentElement("beforeend", styleEl);
}, [workspaceStore.setting.customStyle]);
useEffect(() => {
const root = document.documentElement;
if (colorScheme === "light") {
root.classList.remove("dark");
} else if (colorScheme === "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);
};
}
}, [colorScheme]);
return !loading ? (
<>
<DemoBanner />
<Outlet />
</>
) : (
<></>
);
}
export default App;

View File

@ -1,4 +1,5 @@
import { Button, Link, Modal, ModalDialog } from "@mui/joy"; import { Button, Link, Modal, ModalDialog } from "@mui/joy";
import { useTranslation } from "react-i18next";
import Icon from "./Icon"; import Icon from "./Icon";
interface Props { interface Props {
@ -7,12 +8,13 @@ interface Props {
const AboutDialog: React.FC<Props> = (props: Props) => { const AboutDialog: React.FC<Props> = (props: Props) => {
const { onClose } = props; const { onClose } = props;
const { t } = useTranslation();
return ( return (
<Modal open={true}> <Modal open={true}>
<ModalDialog> <ModalDialog>
<div className="w-full flex flex-row justify-between items-center"> <div className="w-full flex flex-row justify-between items-center">
<span className="text-lg font-medium">About</span> <span className="text-lg font-medium">{t("common.about")}</span>
<Button variant="plain" onClick={onClose}> <Button variant="plain" onClick={onClose}>
<Icon.X className="w-5 h-auto text-gray-600" /> <Icon.X className="w-5 h-auto text-gray-600" />
</Button> </Button>

View File

@ -54,7 +54,7 @@ const Alert: React.FC<Props> = (props: Props) => {
<div className="w-80"> <div className="w-80">
<p className="content-text mb-4">{content}</p> <p className="content-text mb-4">{content}</p>
<div className="w-full flex flex-row justify-end items-center space-x-2"> <div className="w-full flex flex-row justify-end items-center space-x-2">
<Button variant="plain" onClick={handleCloseBtnClick}> <Button variant="plain" color="neutral" onClick={handleCloseBtnClick}>
{closeBtnText} {closeBtnText}
</Button> </Button>
<Button color={style} onClick={handleConfirmBtnClick}> <Button color={style} onClick={handleConfirmBtnClick}>

View File

@ -1,5 +1,6 @@
import classNames from "classnames"; import classNames from "classnames";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import * as api from "../helpers/api"; import * as api from "../helpers/api";
import Icon from "./Icon"; import Icon from "./Icon";
@ -10,6 +11,7 @@ interface Props {
const AnalyticsView: React.FC<Props> = (props: Props) => { const AnalyticsView: React.FC<Props> = (props: Props) => {
const { shortcutId, className } = props; const { shortcutId, className } = props;
const { t } = useTranslation();
const [analytics, setAnalytics] = useState<AnalysisData | null>(null); const [analytics, setAnalytics] = useState<AnalysisData | null>(null);
const [selectedDeviceTab, setSelectedDeviceTab] = useState<"os" | "browser">("browser"); const [selectedDeviceTab, setSelectedDeviceTab] = useState<"os" | "browser">("browser");
@ -24,17 +26,17 @@ const AnalyticsView: React.FC<Props> = (props: Props) => {
{analytics ? ( {analytics ? (
<> <>
<div className="w-full"> <div className="w-full">
<p className="w-full h-8 px-2">Top Sources</p> <p className="w-full h-8 px-2 dark:text-gray-500">{t("analytics.top-sources")}</p>
<div className="w-full mt-1 overflow-hidden shadow ring-1 ring-black ring-opacity-5 rounded-lg"> <div className="w-full mt-1 overflow-hidden shadow ring-1 ring-black ring-opacity-5 rounded-lg dark:ring-zinc-800">
<div className="w-full divide-y divide-gray-300"> <div className="w-full divide-y divide-gray-300 dark:divide-zinc-700">
<div className="w-full flex flex-row justify-between items-center"> <div className="w-full flex flex-row justify-between items-center">
<span className="py-2 px-2 text-left font-semibold text-sm text-gray-500">Source</span> <span className="py-2 px-2 text-left font-semibold text-sm text-gray-500">{t("analytics.source")}</span>
<span className="py-2 pr-2 text-right font-semibold text-sm text-gray-500">Visitors</span> <span className="py-2 pr-2 text-right font-semibold text-sm text-gray-500">{t("analytics.visitors")}</span>
</div> </div>
<div className="w-full divide-y divide-gray-200"> <div className="w-full divide-y divide-gray-200 dark:divide-zinc-800">
{analytics.referenceData.map((reference) => ( {analytics.referenceData.map((reference) => (
<div key={reference.name} className="w-full flex flex-row justify-between items-center"> <div key={reference.name} className="w-full flex flex-row justify-between items-center">
<span className="whitespace-nowrap py-2 px-2 text-sm truncate text-gray-900"> <span className="whitespace-nowrap py-2 px-2 text-sm truncate text-gray-900 dark:text-gray-500">
{reference.name ? ( {reference.name ? (
<a className="hover:underline hover:text-blue-600" href={reference.name} target="_blank"> <a className="hover:underline hover:text-blue-600" href={reference.name} target="_blank">
{reference.name} {reference.name}
@ -53,24 +55,24 @@ const AnalyticsView: React.FC<Props> = (props: Props) => {
<div className="w-full"> <div className="w-full">
<div className="w-full h-8 px-2 flex flex-row justify-between items-center"> <div className="w-full h-8 px-2 flex flex-row justify-between items-center">
<span>Devices</span> <span className="dark:text-gray-500">{t("analytics.devices")}</span>
<div> <div>
<button <button
className={`whitespace-nowrap border-b-2 px-1 text-sm font-medium ${ className={`whitespace-nowrap border-b-2 px-1 text-sm font-medium ${
selectedDeviceTab === "browser" selectedDeviceTab === "browser"
? "border-blue-600 text-blue-600" ? "border-blue-600 text-blue-600"
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700" : "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:hover:border-zinc-700"
}`} }`}
onClick={() => setSelectedDeviceTab("browser")} onClick={() => setSelectedDeviceTab("browser")}
> >
Browser {t("analytics.browser")}
</button> </button>
<span className="text-gray-200 font-mono mx-1">/</span> <span className="text-gray-200 font-mono mx-1 dark:text-gray-500">/</span>
<button <button
className={`whitespace-nowrap border-b-2 px-1 text-sm font-medium ${ className={`whitespace-nowrap border-b-2 px-1 text-sm font-medium ${
selectedDeviceTab === "os" selectedDeviceTab === "os"
? "border-blue-600 text-blue-600" ? "border-blue-600 text-blue-600"
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700" : "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:hover:border-zinc-700"
}`} }`}
onClick={() => setSelectedDeviceTab("os")} onClick={() => setSelectedDeviceTab("os")}
> >
@ -79,17 +81,19 @@ const AnalyticsView: React.FC<Props> = (props: Props) => {
</div> </div>
</div> </div>
<div className="w-full mt-1 overflow-hidden shadow ring-1 ring-black ring-opacity-5 rounded-lg"> <div className="w-full mt-1 overflow-hidden shadow ring-1 ring-black ring-opacity-5 rounded-lg dark:ring-zinc-800">
{selectedDeviceTab === "browser" ? ( {selectedDeviceTab === "browser" ? (
<div className="w-full divide-y divide-gray-300"> <div className="w-full divide-y divide-gray-300 dark:divide-zinc-700">
<div className="w-full flex flex-row justify-between items-center"> <div className="w-full flex flex-row justify-between items-center">
<span className="py-2 px-2 text-left text-sm font-semibold text-gray-500">Browsers</span> <span className="py-2 px-2 text-left text-sm font-semibold text-gray-500">{t("analytics.browsers")}</span>
<span className="py-2 pr-2 text-right text-sm font-semibold text-gray-500">Visitors</span> <span className="py-2 pr-2 text-right text-sm font-semibold text-gray-500">{t("analytics.visitors")}</span>
</div> </div>
<div className="w-full divide-y divide-gray-200"> <div className="w-full divide-y divide-gray-200 dark:divide-zinc-800">
{analytics.browserData.map((reference) => ( {analytics.browserData.map((reference) => (
<div key={reference.name} className="w-full flex flex-row justify-between items-center"> <div key={reference.name} className="w-full flex flex-row justify-between items-center">
<span className="whitespace-nowrap py-2 px-2 text-sm text-gray-900 truncate">{reference.name || "Unknown"}</span> <span className="whitespace-nowrap py-2 px-2 text-sm text-gray-900 truncate dark:text-gray-500">
{reference.name || "Unknown"}
</span>
<span className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right shrink-0">{reference.count}</span> <span className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right shrink-0">{reference.count}</span>
</div> </div>
))} ))}
@ -98,8 +102,8 @@ const AnalyticsView: React.FC<Props> = (props: Props) => {
) : ( ) : (
<div className="w-full divide-y divide-gray-300"> <div className="w-full divide-y divide-gray-300">
<div className="w-full flex flex-row justify-between items-center"> <div className="w-full flex flex-row justify-between items-center">
<span className="py-2 px-2 text-left text-sm font-semibold text-gray-500">Operating system</span> <span className="py-2 px-2 text-left text-sm font-semibold text-gray-500">{t("analytics.operating-system")}</span>
<span className="py-2 pr-2 text-right text-sm font-semibold text-gray-500">Visitors</span> <span className="py-2 pr-2 text-right text-sm font-semibold text-gray-500">{t("analytics.visitors")}</span>
</div> </div>
<div className="w-full divide-y divide-gray-200"> <div className="w-full divide-y divide-gray-200">
{analytics.deviceData.map((device) => ( {analytics.deviceData.map((device) => (
@ -117,7 +121,7 @@ const AnalyticsView: React.FC<Props> = (props: Props) => {
) : ( ) : (
<div className="py-12 w-full flex flex-row justify-center items-center opacity-80"> <div className="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" />
loading {t("common.loading")}
</div> </div>
)} )}
</div> </div>

View File

@ -0,0 +1,9 @@
const BetaBadge = () => {
return (
<div className="text-xs border px-1 text-gray-500 bg-gray-100 rounded-full dark:bg-zinc-800 dark:border-zinc-700">
<span>Beta</span>
</div>
);
};
export default BetaBadge;

View File

@ -1,6 +1,7 @@
import { Button, Input, Modal, ModalDialog } from "@mui/joy"; 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 useLoading from "../hooks/useLoading"; import useLoading from "../hooks/useLoading";
import useUserStore from "../stores/v1/user"; import useUserStore from "../stores/v1/user";
import Icon from "./Icon"; import Icon from "./Icon";
@ -11,6 +12,7 @@ interface Props {
const ChangePasswordDialog: React.FC<Props> = (props: Props) => { const ChangePasswordDialog: React.FC<Props> = (props: Props) => {
const { onClose } = props; const { onClose } = props;
const { t } = useTranslation();
const userStore = useUserStore(); const userStore = useUserStore();
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
const [newPasswordAgain, setNewPasswordAgain] = useState(""); const [newPasswordAgain, setNewPasswordAgain] = useState("");
@ -77,10 +79,10 @@ const ChangePasswordDialog: React.FC<Props> = (props: Props) => {
</div> </div>
<div className="w-full flex flex-row justify-end items-center space-x-2"> <div className="w-full flex flex-row justify-end items-center space-x-2">
<Button variant="plain" disabled={requestState.isLoading} onClick={handleCloseBtnClick}> <Button variant="plain" disabled={requestState.isLoading} onClick={handleCloseBtnClick}>
Cancel {t("common.cancel")}
</Button> </Button>
<Button color="primary" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={handleSaveBtnClick}> <Button color="primary" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={handleSaveBtnClick}>
Save {t("common.save")}
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -0,0 +1,137 @@
import { Button, Input, Modal, ModalDialog, Radio, RadioGroup } from "@mui/joy";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { userServiceClient } from "@/grpcweb";
import useLoading from "../hooks/useLoading";
import useUserStore from "../stores/v1/user";
import Icon from "./Icon";
interface Props {
onClose: () => void;
onConfirm?: () => void;
}
const expirationOptions = [
{
label: "8 hours",
value: 3600 * 8,
},
{
label: "1 month",
value: 3600 * 24 * 30,
},
{
label: "Never",
value: 0,
},
];
interface State {
description: string;
expiration: number;
}
const CreateAccessTokenDialog: React.FC<Props> = (props: Props) => {
const { onClose, onConfirm } = props;
const { t } = useTranslation();
const currentUser = useUserStore().getCurrentUser();
const [state, setState] = useState({
description: "",
expiration: 3600 * 8,
});
const requestState = useLoading(false);
const setPartialState = (partialState: Partial<State>) => {
setState({
...state,
...partialState,
});
};
const handleDescriptionInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({
description: e.target.value,
});
};
const handleRoleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({
expiration: Number(e.target.value),
});
};
const handleSaveBtnClick = async () => {
if (!state.description) {
toast.error("Description is required");
return;
}
try {
await userServiceClient.createUserAccessToken({
id: currentUser.id,
description: state.description,
expiresAt: state.expiration ? new Date(Date.now() + state.expiration * 1000) : undefined,
});
if (onConfirm) {
onConfirm();
}
onClose();
} catch (error: any) {
console.error(error);
toast.error(error.response.data.message);
}
};
return (
<Modal open={true}>
<ModalDialog>
<div className="flex flex-row justify-between items-center w-80 sm:w-96 mb-4">
<span className="text-lg font-medium">Create Access Token</span>
<Button variant="plain" onClick={onClose}>
<Icon.X className="w-5 h-auto text-gray-600" />
</Button>
</div>
<div>
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">
Description <span className="text-red-600">*</span>
</span>
<div className="relative w-full">
<Input
className="w-full"
type="text"
placeholder="Some description"
value={state.description}
onChange={handleDescriptionInputChange}
/>
</div>
</div>
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">
Expiration <span className="text-red-600">*</span>
</span>
<div className="w-full flex flex-row justify-start items-center text-base">
<RadioGroup orientation="horizontal" value={state.expiration} onChange={handleRoleInputChange}>
{expirationOptions.map((option) => (
<Radio key={option.value} value={option.value} checked={state.expiration === option.value} label={option.label} />
))}
</RadioGroup>
</div>
</div>
<div className="w-full flex flex-row justify-end items-center mt-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={handleSaveBtnClick}>
{t("common.create")}
</Button>
</div>
</div>
</ModalDialog>
</Modal>
);
};
export default CreateAccessTokenDialog;

View File

@ -1,15 +1,17 @@
import { Button, Divider, Input, Modal, ModalDialog, Radio, RadioGroup, Textarea } from "@mui/joy"; import { Button, Divider, Input, Modal, ModalDialog, Radio, RadioGroup, Textarea } from "@mui/joy";
import classnames from "classnames"; import classnames from "classnames";
import { isUndefined } 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 { useAppSelector } from "@/stores";
import useLoading from "../hooks/useLoading"; import useLoading from "../hooks/useLoading";
import { shortcutService } from "../services"; import { shortcutService } from "../services";
import Icon from "./Icon"; import Icon from "./Icon";
interface Props { interface Props {
shortcutId?: ShortcutId; shortcutId?: ShortcutId;
initialShortcut?: Partial<Shortcut>;
onClose: () => void; onClose: () => void;
onConfirm?: () => void; onConfirm?: () => void;
} }
@ -21,8 +23,9 @@ interface State {
const visibilities: Visibility[] = ["PRIVATE", "WORKSPACE", "PUBLIC"]; const visibilities: Visibility[] = ["PRIVATE", "WORKSPACE", "PUBLIC"];
const CreateShortcutDialog: React.FC<Props> = (props: Props) => { const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
const { onClose, onConfirm, shortcutId } = props; const { onClose, onConfirm, shortcutId, initialShortcut } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const { shortcutList } = useAppSelector((state) => state.shortcut);
const [state, setState] = useState<State>({ const [state, setState] = useState<State>({
shortcutCreate: { shortcutCreate: {
name: "", name: "",
@ -36,11 +39,13 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
description: "", description: "",
image: "", image: "",
}, },
...initialShortcut,
}, },
}); });
const [showAdditionalFields, setShowAdditionalFields] = useState<boolean>(false); const [showAdditionalFields, setShowAdditionalFields] = useState<boolean>(false);
const [showOpenGraphMetadata, setShowOpenGraphMetadata] = useState<boolean>(false); const [showOpenGraphMetadata, setShowOpenGraphMetadata] = useState<boolean>(false);
const [tag, setTag] = useState<string>(""); const [tag, setTag] = useState<string>("");
const tagSuggestions = uniq(shortcutList.map((shortcut) => shortcut.tags).flat());
const requestState = useLoading(false); const requestState = useLoading(false);
const isCreating = isUndefined(shortcutId); const isCreating = isUndefined(shortcutId);
@ -74,7 +79,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({ setPartialState({
shortcutCreate: Object.assign(state.shortcutCreate, { shortcutCreate: Object.assign(state.shortcutCreate, {
name: e.target.value.replace(/\s+/g, "-").toLowerCase(), name: e.target.value.replace(/\s+/g, "-"),
}), }),
}); });
}; };
@ -149,6 +154,14 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
}); });
}; };
const handleTagSuggestionsClick = (suggestion: string) => {
if (tag === "") {
setTag(suggestion);
} else {
setTag(`${tag} ${suggestion}`);
}
};
const handleSaveBtnClick = async () => { const handleSaveBtnClick = async () => {
if (!state.shortcutCreate.name) { if (!state.shortcutCreate.name) {
toast.error("Name is required"); toast.error("Name is required");
@ -164,13 +177,13 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
title: state.shortcutCreate.title, title: state.shortcutCreate.title,
description: state.shortcutCreate.description, description: state.shortcutCreate.description,
visibility: state.shortcutCreate.visibility, visibility: state.shortcutCreate.visibility,
tags: tag.split(" "), tags: tag.split(" ").filter(Boolean),
openGraphMetadata: state.shortcutCreate.openGraphMetadata, openGraphMetadata: state.shortcutCreate.openGraphMetadata,
}); });
} else { } else {
await shortcutService.createShortcut({ await shortcutService.createShortcut({
...state.shortcutCreate, ...state.shortcutCreate,
tags: tag.split(" "), tags: tag.split(" ").filter(Boolean),
}); });
} }
@ -220,6 +233,22 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
<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">Tags</span> <span className="mb-2">Tags</span>
<Input className="w-full" type="text" placeholder="github slash" value={tag} onChange={handleTagsInputChange} /> <Input className="w-full" type="text" placeholder="github slash" value={tag} onChange={handleTagsInputChange} />
{tagSuggestions.length > 0 && (
<div className="w-full flex flex-row justify-start items-start mt-2">
<Icon.Asterisk className="w-4 h-auto shrink-0 mx-1 text-gray-400 dark:text-gray-600" />
<div className="w-auto flex flex-row justify-start items-start flex-wrap gap-x-2 gap-y-1">
{tagSuggestions.map((tag) => (
<span
className="text-gray-600 dark:text-gray-500 cursor-pointer max-w-[6rem] truncate block text-sm flex-nowrap leading-4 hover:text-black dark:hover:text-gray-400"
key={tag}
onClick={() => handleTagSuggestionsClick(tag)}
>
{tag}
</span>
))}
</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> <span className="mb-2">Visibility</span>
@ -230,16 +259,16 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
))} ))}
</RadioGroup> </RadioGroup>
</div> </div>
<p className="mt-3 text-sm text-gray-500 w-full bg-gray-100 border border-gray-200 px-2 py-1 rounded-md"> <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.${state.shortcutCreate.visibility.toLowerCase()}.description`)} {t(`shortcut.visibility.${state.shortcutCreate.visibility.toLowerCase()}.description`)}
</p> </p>
</div> </div>
<Divider className="text-gray-500">Optional</Divider> <Divider className="text-gray-500">Optional</Divider>
<div className="w-full flex flex-col justify-start items-start border rounded-md overflow-hidden my-3"> <div className="w-full flex flex-col justify-start items-start border rounded-md overflow-hidden my-3 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", "w-full flex flex-row justify-between items-center px-2 py-1 cursor-pointer hover:bg-gray-100 dark:hover:bg-zinc-800",
showAdditionalFields ? "bg-gray-100 border-b" : "" showAdditionalFields ? "bg-gray-100 border-b dark:bg-zinc-800 dark:border-b-zinc-700" : ""
)} )}
onClick={() => setShowAdditionalFields(!showAdditionalFields)} onClick={() => setShowAdditionalFields(!showAdditionalFields)}
> >
@ -275,17 +304,15 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
</div> </div>
)} )}
</div> </div>
<div className="w-full flex flex-col justify-start items-start border rounded-md overflow-hidden"> <div className="w-full flex flex-col justify-start items-start border rounded-md overflow-hidden dark:border-zinc-800">
<div <div
className={`w-full flex flex-row justify-between items-center px-2 py-1 cursor-pointer hover:bg-gray-100 ${ className={classnames(
showOpenGraphMetadata ? "bg-gray-100 border-b" : "" "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" : ""
)}
onClick={() => setShowOpenGraphMetadata(!showOpenGraphMetadata)} onClick={() => setShowOpenGraphMetadata(!showOpenGraphMetadata)}
> >
<span className="text-sm flex flex-row justify-start items-center"> <span className="text-sm flex flex-row justify-start items-center">Social media metadata</span>
Social media metadata
<Icon.Sparkles className="ml-1 w-4 h-auto text-blue-600" />
</span>
<button className="w-7 h-7 p-1 rounded-md"> <button className="w-7 h-7 p-1 rounded-md">
<Icon.ChevronDown className={classnames("w-4 h-auto text-gray-500", showOpenGraphMetadata ? "transform rotate-180" : "")} /> <Icon.ChevronDown className={classnames("w-4 h-auto text-gray-500", showOpenGraphMetadata ? "transform rotate-180" : "")} />
</button> </button>
@ -331,10 +358,10 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
<div className="w-full flex flex-row justify-end items-center mt-4 space-x-2"> <div className="w-full flex flex-row justify-end items-center mt-4 space-x-2">
<Button color="neutral" variant="plain" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={onClose}> <Button color="neutral" variant="plain" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={onClose}>
Cancel {t("common.cancel")}
</Button> </Button>
<Button color="primary" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={handleSaveBtnClick}> <Button color="primary" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={handleSaveBtnClick}>
Save {t("common.save")}
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -2,6 +2,7 @@ import { Button, Input, Modal, ModalDialog, Radio, RadioGroup } 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 useLoading from "../hooks/useLoading"; import useLoading from "../hooks/useLoading";
import useUserStore from "../stores/v1/user"; import useUserStore from "../stores/v1/user";
import Icon from "./Icon"; import Icon from "./Icon";
@ -20,6 +21,7 @@ const roles: Role[] = ["USER", "ADMIN"];
const CreateUserDialog: React.FC<Props> = (props: Props) => { const CreateUserDialog: React.FC<Props> = (props: Props) => {
const { onClose, onConfirm, user } = props; const { onClose, onConfirm, user } = props;
const { t } = useTranslation();
const userStore = useUserStore(); const userStore = useUserStore();
const [state, setState] = useState<State>({ const [state, setState] = useState<State>({
userCreate: { userCreate: {
@ -185,10 +187,10 @@ const CreateUserDialog: React.FC<Props> = (props: Props) => {
</div> </div>
<div className="w-full flex flex-row justify-end items-center mt-4 space-x-2"> <div className="w-full flex flex-row justify-end items-center mt-4 space-x-2">
<Button color="neutral" variant="plain" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={onClose}> <Button color="neutral" variant="plain" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={onClose}>
Cancel {t("common.cancel")}
</Button> </Button>
<Button color="primary" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={handleSaveBtnClick}> <Button color="primary" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={handleSaveBtnClick}>
Save {t("common.save")}
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -1,20 +1,16 @@
import { globalService } from "../services"; import useWorkspaceStore from "@/stores/v1/workspace";
import Icon from "./Icon"; import Icon from "./Icon";
const DemoBanner: React.FC = () => { const DemoBanner: React.FC = () => {
const { const workspaceStore = useWorkspaceStore();
workspaceProfile: { const shouldShow = workspaceStore.profile.mode === "demo";
profile: { mode },
},
} = globalService.getState();
const shouldShow = mode === "demo";
if (!shouldShow) return null; if (!shouldShow) return null;
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-6xl px-4 md:px-12 flex flex-row justify-between items-center gap-x-3"> <div className="w-full max-w-6xl 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 bookmarks and link sharing platform</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/boojack/slash#deploy-with-docker-in-seconds" href="https://github.com/boojack/slash#deploy-with-docker-in-seconds"

View File

@ -1,6 +1,7 @@
import { Button, Input, Modal, ModalDialog } from "@mui/joy"; 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 useLoading from "../hooks/useLoading"; import useLoading from "../hooks/useLoading";
import useUserStore from "../stores/v1/user"; import useUserStore from "../stores/v1/user";
import Icon from "./Icon"; import Icon from "./Icon";
@ -11,6 +12,7 @@ interface Props {
const EditUserinfoDialog: React.FC<Props> = (props: Props) => { const EditUserinfoDialog: React.FC<Props> = (props: Props) => {
const { onClose } = props; const { onClose } = props;
const { t } = useTranslation();
const userStore = useUserStore(); const userStore = useUserStore();
const currentUser = userStore.getCurrentUser(); const currentUser = userStore.getCurrentUser();
const [email, setEmail] = useState(currentUser.email); const [email, setEmail] = useState(currentUser.email);
@ -64,19 +66,19 @@ const EditUserinfoDialog: 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">Email</span> <span className="mb-2">{t("common.email")}</span>
<Input className="w-full" type="text" value={email} onChange={handleEmailChanged} /> <Input className="w-full" type="text" value={email} onChange={handleEmailChanged} />
</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">Nickname</span> <span className="mb-2">{t("user.nickname")}</span>
<Input className="w-full" type="text" value={nickname} onChange={handleNicknameChanged} /> <Input className="w-full" type="text" value={nickname} onChange={handleNicknameChanged} />
</div> </div>
<div className="w-full flex flex-row justify-end items-center space-x-2"> <div className="w-full flex flex-row justify-end items-center space-x-2">
<Button variant="plain" disabled={requestState.isLoading} onClick={handleCloseBtnClick}> <Button variant="plain" disabled={requestState.isLoading} onClick={handleCloseBtnClick}>
Cancel {t("common.cancel")}
</Button> </Button>
<Button color="primary" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={handleSaveBtnClick}> <Button color="primary" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={handleSaveBtnClick}>
Save {t("common.save")}
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -2,6 +2,7 @@ import { Button, Modal, ModalDialog } from "@mui/joy";
import { QRCodeCanvas } from "qrcode.react"; 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 { absolutifyLink } from "../helpers/utils"; import { absolutifyLink } from "../helpers/utils";
import Icon from "./Icon"; import Icon from "./Icon";
@ -12,6 +13,7 @@ interface Props {
const GenerateQRCodeDialog: React.FC<Props> = (props: Props) => { const GenerateQRCodeDialog: React.FC<Props> = (props: Props) => {
const { shortcut, onClose } = props; const { shortcut, onClose } = props;
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const shortcutLink = absolutifyLink(`/s/${shortcut.name}`); const shortcutLink = absolutifyLink(`/s/${shortcut.name}`);
@ -49,7 +51,7 @@ const GenerateQRCodeDialog: React.FC<Props> = (props: Props) => {
<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}>
<Icon.Download className="w-4 h-auto mr-1" /> <Icon.Download className="w-4 h-auto mr-1" />
Download {t("common.download")}
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -0,0 +1,91 @@
import { Avatar } from "@mui/joy";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import useWorkspaceStore from "@/stores/v1/workspace";
import { PlanType } from "@/types/proto/api/v2/subscription_service";
import * as api from "../helpers/api";
import useUserStore from "../stores/v1/user";
import AboutDialog from "./AboutDialog";
import Icon from "./Icon";
import Dropdown from "./common/Dropdown";
const Header: React.FC = () => {
const { t } = useTranslation();
const workspaceStore = useWorkspaceStore();
const currentUser = useUserStore().getCurrentUser();
const [showAboutDialog, setShowAboutDialog] = useState<boolean>(false);
const profile = workspaceStore.profile;
const isAdmin = currentUser.role === "ADMIN";
const handleSignOutButtonClick = async () => {
await api.signout();
window.location.href = "/auth";
};
return (
<>
<div className="w-full bg-gray-50 dark:bg-zinc-900 border-b border-b-gray-200 dark:border-b-zinc-800">
<div className="w-full max-w-6xl mx-auto px-3 md:px-12 py-5 flex flex-row justify-between items-center">
<div className="flex flex-row justify-start items-center shrink mr-2">
<Link to="/" className="text-lg cursor-pointer flex flex-row justify-start items-center dark:text-gray-400">
<img id="logo-img" src="/logo.png" className="w-8 h-auto mr-2 -mt-0.5 dark:opacity-80" alt="" />
Slash
</Link>
{profile.plan === PlanType.PRO && (
<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
</span>
)}
</div>
<div className="relative flex-shrink-0">
<Dropdown
trigger={
<button className="flex flex-row justify-end items-center cursor-pointer">
<Avatar size="sm" variant="plain" />
<span className="dark:text-gray-400">{currentUser.nickname}</span>
<Icon.ChevronDown className="ml-2 w-5 h-auto text-gray-600 dark:text-gray-400" />
</button>
}
actionsClassName="!w-32"
actions={
<>
<Link
to="/setting/general"
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"
>
<Icon.User className="w-4 h-auto mr-2" /> {t("user.profile")}
</Link>
{isAdmin && (
<Link
to="/setting/workspace"
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"
>
<Icon.Settings className="w-4 h-auto mr-2" /> {t("settings.self")}
</Link>
)}
<button
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"
onClick={() => setShowAboutDialog(true)}
>
<Icon.Info className="w-4 h-auto mr-2" /> {t("common.about")}
</button>
<button
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"
onClick={() => handleSignOutButtonClick()}
>
<Icon.LogOut className="w-4 h-auto mr-2" /> {t("auth.sign-out")}
</button>
</>
}
></Dropdown>
</div>
</div>
</div>
{showAboutDialog && <AboutDialog onClose={() => setShowAboutDialog(false)} />}
</>
);
};
export default Header;

View File

@ -0,0 +1,3 @@
import * as Icon from "lucide-react";
export default Icon;

View File

@ -1,9 +1,11 @@
import classNames from "classnames"; import classNames from "classnames";
import { useTranslation } from "react-i18next";
import { useAppSelector } from "../stores"; import { useAppSelector } from "../stores";
import useViewStore from "../stores/v1/view"; import useViewStore from "../stores/v1/view";
import Icon from "./Icon"; import Icon from "./Icon";
const Navigator = () => { const Navigator = () => {
const { t } = useTranslation();
const viewStore = useViewStore(); const viewStore = useViewStore();
const { shortcutList } = useAppSelector((state) => state.shortcut); const { shortcutList } = useAppSelector((state) => state.shortcut);
const tags = shortcutList.map((shortcut) => shortcut.tags).flat(); const tags = shortcutList.map((shortcut) => shortcut.tags).flat();
@ -14,30 +16,36 @@ const Navigator = () => {
<div className="w-full flex flex-row justify-start items-center mb-4 gap-1 sm:flex-wrap overflow-x-auto no-scrollbar"> <div className="w-full flex flex-row justify-start items-center mb-4 gap-1 sm:flex-wrap overflow-x-auto no-scrollbar">
<button <button
className={classNames( className={classNames(
"flex flex-row justify-center items-center px-2 leading-7 text-sm rounded-md hover:bg-gray-200", "flex flex-row justify-center items-center px-2 leading-7 text-sm dark:text-gray-400 rounded-md",
currentTab === "tab:all" ? "!bg-gray-600 text-white shadow" : "" currentTab === "tab:all"
? "bg-gray-600 dark:bg-zinc-700 text-white dark:text-gray-400 shadow"
: "hover:bg-gray-200 dark:hover:bg-zinc-700"
)} )}
onClick={() => viewStore.setFilter({ tab: "tab:all" })} onClick={() => viewStore.setFilter({ tab: "tab:all" })}
> >
<Icon.CircleSlash className="w-4 h-auto mr-1" /> <Icon.CircleSlash className="w-4 h-auto mr-1" />
<span className="font-normal">All</span> <span className="font-normal">{t("filter.all")}</span>
</button> </button>
<button <button
className={classNames( className={classNames(
"flex flex-row justify-center items-center px-2 leading-7 text-sm rounded-md hover:bg-gray-200", "flex flex-row justify-center items-center px-2 leading-7 text-sm dark:text-gray-400 rounded-md",
currentTab === "tab:mine" ? "!bg-gray-600 text-white shadow" : "" currentTab === "tab:mine"
? "bg-gray-600 dark:bg-zinc-700 text-white dark:text-gray-400 shadow"
: "hover:bg-gray-200 dark:hover:bg-zinc-700"
)} )}
onClick={() => viewStore.setFilter({ tab: "tab:mine" })} onClick={() => viewStore.setFilter({ tab: "tab:mine" })}
> >
<Icon.User className="w-4 h-auto mr-1" /> <Icon.User className="w-4 h-auto mr-1" />
<span className="font-normal">Mine</span> <span className="font-normal">{t("filter.mine")}</span>
</button> </button>
{Array.from(sortedTagMap.keys()).map((tag) => ( {Array.from(sortedTagMap.keys()).map((tag) => (
<button <button
key={tag} key={tag}
className={classNames( className={classNames(
"flex flex-row justify-center items-center px-2 leading-7 text-sm rounded-md hover:bg-gray-200", "flex flex-row justify-center items-center px-2 leading-7 text-sm dark:text-gray-400 rounded-md",
currentTab === `tag:${tag}` ? "!bg-gray-600 text-white shadow" : "" currentTab === `tag:${tag}`
? "bg-gray-600 dark:bg-zinc-700 text-white dark:text-gray-400 shadow"
: "hover:bg-gray-200 dark:hover:bg-zinc-700"
)} )}
onClick={() => viewStore.setFilter({ tab: `tag:${tag}`, tag: undefined })} onClick={() => viewStore.setFilter({ tab: `tag:${tag}`, tag: undefined })}
> >

View File

@ -1,12 +1,13 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
import useNavigateTo from "@/hooks/useNavigateTo";
import { shortcutService } from "../services"; import { shortcutService } from "../services";
import useUserStore from "../stores/v1/user"; import useUserStore from "../stores/v1/user";
import { showCommonDialog } from "./Alert"; import { showCommonDialog } from "./Alert";
import AnalyticsDialog from "./AnalyticsDialog";
import Dropdown from "./common/Dropdown";
import CreateShortcutDialog from "./CreateShortcutDialog"; import CreateShortcutDialog from "./CreateShortcutDialog";
import GenerateQRCodeDialog from "./GenerateQRCodeDialog"; import GenerateQRCodeDialog from "./GenerateQRCodeDialog";
import Icon from "./Icon"; import Icon from "./Icon";
import Dropdown from "./common/Dropdown";
interface Props { interface Props {
shortcut: Shortcut; shortcut: Shortcut;
@ -14,10 +15,11 @@ interface Props {
const ShortcutActionsDropdown = (props: Props) => { const ShortcutActionsDropdown = (props: Props) => {
const { shortcut } = props; const { shortcut } = props;
const { t } = useTranslation();
const navigateTo = useNavigateTo();
const currentUser = useUserStore().getCurrentUser(); const currentUser = useUserStore().getCurrentUser();
const [showEditDialog, setShowEditDialog] = useState<boolean>(false); const [showEditDialog, setShowEditDialog] = useState<boolean>(false);
const [showQRCodeDialog, setShowQRCodeDialog] = useState<boolean>(false); const [showQRCodeDialog, setShowQRCodeDialog] = useState<boolean>(false);
const [showAnalyticsDialog, setShowAnalyticsDialog] = useState<boolean>(false);
const havePermission = currentUser.role === "ADMIN" || shortcut.creatorId === currentUser.id; const havePermission = currentUser.role === "ADMIN" || shortcut.creatorId === currentUser.id;
const handleDeleteShortcutButtonClick = (shortcut: Shortcut) => { const handleDeleteShortcutButtonClick = (shortcut: Shortcut) => {
@ -31,40 +33,44 @@ const ShortcutActionsDropdown = (props: Props) => {
}); });
}; };
const gotoAnalytics = () => {
navigateTo(`/shortcut/${shortcut.id}#analytics`);
};
return ( return (
<> <>
<Dropdown <Dropdown
actionsClassName="!w-32" actionsClassName="!w-32 dark:text-gray-500"
actions={ actions={
<> <>
{havePermission && ( {havePermission && (
<button <button
className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded hover:bg-gray-100 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 leading-8 cursor-pointer rounded hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60 dark:hover:bg-zinc-800"
onClick={() => setShowEditDialog(true)} onClick={() => setShowEditDialog(true)}
> >
<Icon.Edit className="w-4 h-auto mr-2" /> Edit <Icon.Edit className="w-4 h-auto mr-2" /> {t("common.edit")}
</button> </button>
)} )}
<button <button
className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded hover:bg-gray-100 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 leading-8 cursor-pointer rounded hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60 dark:hover:bg-zinc-800"
onClick={() => setShowQRCodeDialog(true)} onClick={() => setShowQRCodeDialog(true)}
> >
<Icon.QrCode className="w-4 h-auto mr-2" /> QR Code <Icon.QrCode className="w-4 h-auto mr-2" /> QR Code
</button> </button>
<button <button
className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded hover:bg-gray-100 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 leading-8 cursor-pointer rounded hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60 dark:hover:bg-zinc-800"
onClick={() => setShowAnalyticsDialog(true)} onClick={gotoAnalytics}
> >
<Icon.BarChart2 className="w-4 h-auto mr-2" /> Analytics <Icon.BarChart2 className="w-4 h-auto mr-2" /> {t("analytics.self")}
</button> </button>
{havePermission && ( {havePermission && (
<button <button
className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded text-red-600 hover:bg-gray-100 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 leading-8 cursor-pointer rounded text-red-600 hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60 dark:hover:bg-zinc-800"
onClick={() => { onClick={() => {
handleDeleteShortcutButtonClick(shortcut); handleDeleteShortcutButtonClick(shortcut);
}} }}
> >
<Icon.Trash className="w-4 h-auto mr-2" /> Delete <Icon.Trash className="w-4 h-auto mr-2" /> {t("common.delete")}
</button> </button>
)} )}
</> </>
@ -80,8 +86,6 @@ const ShortcutActionsDropdown = (props: Props) => {
)} )}
{showQRCodeDialog && <GenerateQRCodeDialog shortcut={shortcut} onClose={() => setShowQRCodeDialog(false)} />} {showQRCodeDialog && <GenerateQRCodeDialog shortcut={shortcut} onClose={() => setShowQRCodeDialog(false)} />}
{showAnalyticsDialog && <AnalyticsDialog shortcutId={shortcut.id} onClose={() => setShowAnalyticsDialog(false)} />}
</> </>
); );
}; };

View File

@ -1,14 +1,11 @@
import { Tooltip } from "@mui/joy"; 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 { 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 { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { absolutifyLink } from "../helpers/utils"; import { absolutifyLink, getFaviconWithGoogleS2 } from "../helpers/utils";
import useFaviconStore from "../stores/v1/favicon";
import useViewStore from "../stores/v1/view"; import useViewStore from "../stores/v1/view";
import AnalyticsDialog from "./AnalyticsDialog";
import Icon from "./Icon"; import Icon from "./Icon";
import ShortcutActionsDropdown from "./ShortcutActionsDropdown"; import ShortcutActionsDropdown from "./ShortcutActionsDropdown";
import VisibilityIcon from "./VisibilityIcon"; import VisibilityIcon from "./VisibilityIcon";
@ -21,18 +18,8 @@ const ShortcutView = (props: Props) => {
const { shortcut } = props; const { shortcut } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const viewStore = useViewStore(); const viewStore = useViewStore();
const faviconStore = useFaviconStore();
const [favicon, setFavicon] = useState<string | undefined>(undefined);
const [showAnalyticsDialog, setShowAnalyticsDialog] = useState<boolean>(false);
const shortcutLink = absolutifyLink(`/s/${shortcut.name}`); const shortcutLink = absolutifyLink(`/s/${shortcut.name}`);
const favicon = getFaviconWithGoogleS2(shortcut.link);
useEffect(() => {
faviconStore.getOrFetchUrlFavicon(shortcut.link).then((url) => {
if (url) {
setFavicon(url);
}
});
}, [shortcut.link]);
const handleCopyButtonClick = () => { const handleCopyButtonClick = () => {
copy(shortcutLink); copy(shortcutLink);
@ -41,7 +28,11 @@ const ShortcutView = (props: Props) => {
return ( return (
<> <>
<div className={classNames("group px-4 py-3 w-full flex flex-col justify-start items-start border rounded-lg hover:shadow")}> <div
className={classNames(
"group px-4 py-3 w-full flex flex-col justify-start items-start border rounded-lg hover:shadow dark:border-zinc-700"
)}
>
<div className="w-full flex flex-row justify-between items-center"> <div className="w-full flex flex-row justify-between items-center">
<div className="w-[calc(100%-16px)] flex flex-row justify-start items-center mr-1 shrink-0"> <div className="w-[calc(100%-16px)] flex flex-row justify-start items-center mr-1 shrink-0">
<Link to={`/shortcut/${shortcut.id}`} className={classNames("w-8 h-8 flex justify-center items-center overflow-clip shrink-0")}> <Link to={`/shortcut/${shortcut.id}`} className={classNames("w-8 h-8 flex justify-center items-center overflow-clip shrink-0")}>
@ -55,19 +46,19 @@ const ShortcutView = (props: Props) => {
<div className="w-full flex flex-row justify-start items-center"> <div className="w-full flex flex-row justify-start items-center">
<a <a
className={classNames( className={classNames(
"max-w-[calc(100%-24px) flex flex-row px-1 mr-1 justify-start items-center cursor-pointer rounded-md hover:bg-gray-100 hover:shadow" "max-w-[calc(100%-36px)] flex flex-row px-1 mr-1 justify-start items-center cursor-pointer rounded-md hover:bg-gray-100 hover:shadow dark:hover:bg-zinc-800"
)} )}
target="_blank" target="_blank"
href={shortcutLink} href={shortcutLink}
> >
<div className="truncate"> <div className="truncate">
<span>{shortcut.title}</span> <span className="dark:text-gray-400">{shortcut.title}</span>
{shortcut.title ? ( {shortcut.title ? (
<span className="text-gray-400">(s/{shortcut.name})</span> <span className="text-gray-500">(s/{shortcut.name})</span>
) : ( ) : (
<> <>
<span className="text-gray-400">s/</span> <span className="text-gray-400 dark:text-gray-500">s/</span>
<span className="truncate">{shortcut.name}</span> <span className="truncate dark:text-gray-400">{shortcut.name}</span>
</> </>
)} )}
</div> </div>
@ -77,14 +68,18 @@ const ShortcutView = (props: Props) => {
</a> </a>
<Tooltip title="Copy" variant="solid" placement="top" arrow> <Tooltip title="Copy" variant="solid" placement="top" arrow>
<button <button
className="hidden group-hover:block w-6 h-6 cursor-pointer rounded-md text-gray-500 hover:bg-gray-100 hover:shadow" className="hidden group-hover:block w-6 h-6 cursor-pointer rounded-md text-gray-500 hover:bg-gray-100 hover:shadow dark:hover:bg-zinc-800"
onClick={() => handleCopyButtonClick()} onClick={() => handleCopyButtonClick()}
> >
<Icon.Clipboard className="w-4 h-auto mx-auto" /> <Icon.Clipboard className="w-4 h-auto mx-auto" />
</button> </button>
</Tooltip> </Tooltip>
</div> </div>
<a className="ml-1 w-full text-sm truncate text-gray-400 hover:underline" href={shortcut.link} target="_blank"> <a
className="pl-1 pr-4 w-full text-sm truncate text-gray-400 dark:text-gray-500 hover:underline"
href={shortcut.link}
target="_blank"
>
{shortcut.link} {shortcut.link}
</a> </a>
</div> </div>
@ -98,7 +93,7 @@ const ShortcutView = (props: Props) => {
return ( return (
<span <span
key={tag} key={tag}
className="max-w-[8rem] truncate text-gray-400 text-sm font-mono leading-4 cursor-pointer hover:text-gray-600" className="max-w-[8rem] truncate text-gray-400 dark:text-gray-500 text-sm font-mono leading-4 cursor-pointer hover:opacity-80"
onClick={() => viewStore.setFilter({ tag: tag })} onClick={() => viewStore.setFilter({ tag: tag })}
> >
#{tag} #{tag}
@ -107,16 +102,16 @@ const ShortcutView = (props: Props) => {
})} })}
{shortcut.tags.length === 0 && <span className="text-gray-400 text-sm font-mono leading-4 italic">No tags</span>} {shortcut.tags.length === 0 && <span className="text-gray-400 text-sm font-mono leading-4 italic">No tags</span>}
</div> </div>
<div className="w-full flex mt-2 gap-2"> <div className="w-full flex mt-2 gap-2 overflow-x-auto">
<Tooltip title="Creator" variant="solid" placement="top" arrow> <Tooltip title="Creator" variant="solid" placement="top" arrow>
<div className="w-auto px-2 leading-6 flex flex-row justify-start items-center border rounded-full text-gray-500 text-sm"> <div className="w-auto px-2 leading-6 flex flex-row justify-start items-center flex-nowra whitespace-nowrap border rounded-full text-gray-500 text-sm dark:border-zinc-800">
<Icon.User className="w-4 h-auto mr-1" /> <Icon.User className="w-4 h-auto mr-1" />
<span className="max-w-[4rem] sm:max-w-[6rem] truncate">{shortcut.creator.nickname}</span> <span className="max-w-[4rem] sm:max-w-[6rem] truncate">{shortcut.creator.nickname}</span>
</div> </div>
</Tooltip> </Tooltip>
<Tooltip title={t(`shortcut.visibility.${shortcut.visibility.toLowerCase()}.description`)} variant="solid" placement="top" arrow> <Tooltip title={t(`shortcut.visibility.${shortcut.visibility.toLowerCase()}.description`)} variant="solid" placement="top" arrow>
<div <div
className="w-auto px-2 leading-6 flex flex-row justify-start items-center border rounded-full cursor-pointer text-gray-500 text-sm" className="w-auto px-2 leading-6 flex flex-row justify-start items-center flex-nowrap whitespace-nowrap border rounded-full cursor-pointer text-gray-500 text-sm dark:border-zinc-800"
onClick={() => viewStore.setFilter({ visibility: shortcut.visibility })} onClick={() => viewStore.setFilter({ visibility: shortcut.visibility })}
> >
<VisibilityIcon className="w-4 h-auto mr-1" visibility={shortcut.visibility} /> <VisibilityIcon className="w-4 h-auto mr-1" visibility={shortcut.visibility} />
@ -124,18 +119,16 @@ const ShortcutView = (props: Props) => {
</div> </div>
</Tooltip> </Tooltip>
<Tooltip title="View count" variant="solid" placement="top" arrow> <Tooltip title="View count" variant="solid" placement="top" arrow>
<div <Link
className="w-auto px-2 leading-6 flex flex-row justify-start items-center border rounded-full cursor-pointer text-gray-500 text-sm" to={`/shortcut/${shortcut.id}#analytics`}
onClick={() => setShowAnalyticsDialog(true)} className="w-auto px-2 leading-6 flex flex-row justify-start items-center flex-nowrap whitespace-nowrap border rounded-full cursor-pointer text-gray-500 text-sm dark:border-zinc-800"
> >
<Icon.BarChart2 className="w-4 h-auto mr-1" /> <Icon.BarChart2 className="w-4 h-auto mr-1" />
{shortcut.view} visits {t("shortcut.visits", { count: shortcut.view })}
</div> </Link>
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
{showAnalyticsDialog && <AnalyticsDialog shortcutId={shortcut.id} onClose={() => setShowAnalyticsDialog(false)} />}
</> </>
); );
}; };

View File

@ -1,8 +1,6 @@
import classNames from "classnames"; import classNames from "classnames";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { absolutifyLink } from "../helpers/utils"; import { absolutifyLink, getFaviconWithGoogleS2 } from "../helpers/utils";
import useFaviconStore from "../stores/v1/favicon";
import Icon from "./Icon"; import Icon from "./Icon";
import ShortcutActionsDropdown from "./ShortcutActionsDropdown"; import ShortcutActionsDropdown from "./ShortcutActionsDropdown";
@ -12,23 +10,14 @@ interface Props {
const ShortcutView = (props: Props) => { const ShortcutView = (props: Props) => {
const { shortcut } = props; const { shortcut } = props;
const faviconStore = useFaviconStore();
const [favicon, setFavicon] = useState<string | undefined>(undefined);
const shortcutLink = absolutifyLink(`/s/${shortcut.name}`); const shortcutLink = absolutifyLink(`/s/${shortcut.name}`);
const favicon = getFaviconWithGoogleS2(shortcut.link);
useEffect(() => {
faviconStore.getOrFetchUrlFavicon(shortcut.link).then((url) => {
if (url) {
setFavicon(url);
}
});
}, [shortcut.link]);
return ( return (
<> <>
<div <div
className={classNames( className={classNames(
"group w-full px-3 py-2 flex flex-col justify-start items-start border rounded-lg hover:bg-gray-100 hover:shadow" "group w-full 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-between items-center"> <div className="w-full flex flex-row justify-between items-center">
@ -50,13 +39,13 @@ const ShortcutView = (props: Props) => {
target="_blank" target="_blank"
> >
<div className="truncate"> <div className="truncate">
<span>{shortcut.title}</span> <span className="dark:text-gray-400">{shortcut.title}</span>
{shortcut.title ? ( {shortcut.title ? (
<span className="text-gray-400">(s/{shortcut.name})</span> <span className="text-gray-500">(s/{shortcut.name})</span>
) : ( ) : (
<> <>
<span className="text-gray-400">s/</span> <span className="text-gray-400 dark:text-gray-500">s/</span>
<span className="truncate">{shortcut.name}</span> <span className="truncate dark:text-gray-400">{shortcut.name}</span>
</> </>
)} )}
</div> </div>

View File

@ -16,8 +16,8 @@ const ShortcutsContainer: React.FC<Props> = (props: Props) => {
return ( return (
<div <div
className={classNames( className={classNames(
"w-full grid grid-cols-1 gap-y-2 sm:gap-2", "w-full grid grid-cols-1 gap-3 sm:gap-4",
displayStyle === "full" ? "sm:grid-cols-2" : "grid-cols-2 sm:grid-cols-4 gap-2" displayStyle === "full" ? "sm:grid-cols-2 lg:grid-cols-3" : "grid-cols-2 sm:grid-cols-4"
)} )}
> >
{shortcutList.map((shortcut) => { {shortcutList.map((shortcut) => {

View File

@ -0,0 +1,33 @@
import Accordion from "@mui/joy/Accordion";
import AccordionDetails from "@mui/joy/AccordionDetails";
import AccordionGroup from "@mui/joy/AccordionGroup";
import AccordionSummary from "@mui/joy/AccordionSummary";
const SubscriptionFAQ = () => {
return (
<div className="w-full flex flex-col justify-center items-center">
<h2 className="text-2xl font-semibold mb-8 dark:text-gray-400">Frequently Asked Questions</h2>
<AccordionGroup className="w-full max-w-2xl">
<Accordion>
<AccordionSummary>Can I use the Free plan in my team?</AccordionSummary>
<AccordionDetails>
Of course you can. In the free plan, you can invite up to 5 members to your team. If you need more, you can upgrade to the Pro
plan.
</AccordionDetails>
</Accordion>
<Accordion>
<AccordionSummary>How many devices can the license key be used on?</AccordionSummary>
<AccordionDetails>{`It's unlimited for now, but please don't abuse it.`}</AccordionDetails>
</Accordion>
<Accordion>
<AccordionSummary>{`Can I get a refund if Slash doesn't meet my needs?`}</AccordionSummary>
<AccordionDetails>
Yes, absolutely! You can send a email to me at `stevenlgtm@gmail.com`. I will refund you as soon as possible.
</AccordionDetails>
</Accordion>
</AccordionGroup>
</div>
);
};
export default SubscriptionFAQ;

View File

@ -1,20 +1,16 @@
import { Button, Divider, Option, Select } from "@mui/joy"; import { Divider, Option, Select, Switch } from "@mui/joy";
import { toast } from "react-hot-toast"; import { useTranslation } from "react-i18next";
import useViewStore from "../stores/v1/view"; import useViewStore from "../stores/v1/view";
import Dropdown from "./common/Dropdown";
import Icon from "./Icon"; import Icon from "./Icon";
import Dropdown from "./common/Dropdown";
const ViewSetting = () => { const ViewSetting = () => {
const { t } = useTranslation();
const viewStore = useViewStore(); const viewStore = useViewStore();
const order = viewStore.getOrder(); const order = viewStore.getOrder();
const { field, direction } = order; const { field, direction } = order;
const displayStyle = viewStore.displayStyle || "full"; const displayStyle = viewStore.displayStyle || "full";
const handleReset = () => {
viewStore.setOrder({ field: "name", direction: "asc" });
toast.success("Order reset");
};
return ( return (
<Dropdown <Dropdown
trigger={ trigger={
@ -24,15 +20,18 @@ const ViewSetting = () => {
} }
actionsClassName="!mt-3 !-right-2" actionsClassName="!mt-3 !-right-2"
actions={ actions={
<div className="w-52 p-2 pt-0 gap-2 flex flex-col justify-start items-start" onClick={(e) => e.stopPropagation()}> <div className="w-52 p-2 gap-2 flex flex-col justify-start items-start" onClick={(e) => e.stopPropagation()}>
<div className="w-full flex flex-row justify-between items-center mt-1">
<span className="text-sm font-medium">View order</span>
<Button size="sm" variant="plain" color="neutral" onClick={handleReset}>
<Icon.RefreshCw className="w-4 h-auto text-gray-500" />
</Button>
</div>
<div className="w-full flex flex-row justify-between items-center"> <div className="w-full flex flex-row justify-between items-center">
<span className="text-sm shrink-0 mr-2">Order by</span> <span className="text-sm shrink-0 mr-2">{t("filter.compact-mode")}</span>
<Switch
size="sm"
checked={displayStyle === "compact"}
onChange={(event) => viewStore.setDisplayStyle(event.target.checked ? "compact" : "full")}
/>
</div>
<Divider className="!my-1" />
<div className="w-full flex flex-row justify-between items-center">
<span className="text-sm shrink-0 mr-2">{t("filter.order-by")}</span>
<Select size="sm" value={field} onChange={(_, value) => viewStore.setOrder({ field: value as any })}> <Select size="sm" value={field} onChange={(_, value) => viewStore.setOrder({ field: value as any })}>
<Option value={"name"}>Name</Option> <Option value={"name"}>Name</Option>
<Option value={"updatedTs"}>CreatedAt</Option> <Option value={"updatedTs"}>CreatedAt</Option>
@ -41,20 +40,12 @@ const ViewSetting = () => {
</Select> </Select>
</div> </div>
<div className="w-full flex flex-row justify-between items-center"> <div className="w-full flex flex-row justify-between items-center">
<span className="text-sm shrink-0 mr-2">Direction</span> <span className="text-sm shrink-0 mr-2">{t("filter.direction")}</span>
<Select size="sm" value={direction} onChange={(_, value) => viewStore.setOrder({ direction: value as any })}> <Select size="sm" value={direction} onChange={(_, value) => viewStore.setOrder({ direction: value as any })}>
<Option value={"asc"}>ASC</Option> <Option value={"asc"}>ASC</Option>
<Option value={"desc"}>DESC</Option> <Option value={"desc"}>DESC</Option>
</Select> </Select>
</div> </div>
<Divider />
<div className="w-full flex flex-row justify-between items-center">
<span className="text-sm shrink-0 mr-2">Display</span>
<Select size="sm" value={displayStyle} onChange={(_, value) => viewStore.setDisplayStyle(value as any)}>
<Option value={"full"}>Full</Option>
<Option value={"compact"}>Compact</Option>
</Select>
</div>
</div> </div>
} }
></Dropdown> ></Dropdown>

View File

@ -1,5 +1,4 @@
import { ReactNode, useEffect, useRef } from "react"; import { ReactNode, useEffect, useRef, useState } from "react";
import useToggle from "../../hooks/useToggle";
import Icon from "../Icon"; import Icon from "../Icon";
interface Props { interface Props {
@ -11,14 +10,14 @@ interface Props {
const Dropdown: React.FC<Props> = (props: Props) => { const Dropdown: React.FC<Props> = (props: Props) => {
const { trigger, actions, className, actionsClassName } = props; const { trigger, actions, className, actionsClassName } = props;
const [dropdownStatus, toggleDropdownStatus] = useToggle(false); const [dropdownStatus, setDropdownStatus] = useState(false);
const dropdownWrapperRef = useRef<HTMLDivElement>(null); const dropdownWrapperRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (dropdownStatus) { if (dropdownStatus) {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if (!dropdownWrapperRef.current?.contains(event.target as Node)) { if (!dropdownWrapperRef.current?.contains(event.target as Node)) {
toggleDropdownStatus(false); setDropdownStatus(false);
} }
}; };
@ -35,7 +34,7 @@ const Dropdown: React.FC<Props> = (props: Props) => {
const handleToggleDropdownStatus = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => { const handleToggleDropdownStatus = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
e.stopPropagation(); e.stopPropagation();
toggleDropdownStatus(); setDropdownStatus(!dropdownStatus);
}; };
return ( return (
@ -52,7 +51,7 @@ const Dropdown: React.FC<Props> = (props: Props) => {
</button> </button>
)} )}
<div <div
className={`w-auto mt-1 absolute top-full right-0 flex flex-col justify-start items-start bg-white z-1 border p-1 rounded-md shadow ${ className={`w-auto mt-1 absolute top-full right-0 flex flex-col justify-start items-start bg-white dark:bg-zinc-900 z-1 border dark:border-zinc-800 p-1 rounded-md shadow ${
actionsClassName ?? "" actionsClassName ?? ""
} ${dropdownStatus ? "" : "!hidden"}`} } ${dropdownStatus ? "" : "!hidden"}`}
> >

View File

@ -0,0 +1,150 @@
import { Button, IconButton } from "@mui/joy";
import copy from "copy-to-clipboard";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { userServiceClient } from "@/grpcweb";
import { UserAccessToken } from "@/types/proto/api/v2/user_service";
import useUserStore from "../../stores/v1/user";
import { showCommonDialog } from "../Alert";
import CreateAccessTokenDialog from "../CreateAccessTokenDialog";
import Icon from "../Icon";
const listAccessTokens = async (userId: number) => {
const { accessTokens } = await userServiceClient.listUserAccessTokens({
id: userId,
});
return accessTokens;
};
const AccessTokenSection = () => {
const { t } = useTranslation();
const currentUser = useUserStore().getCurrentUser();
const [userAccessTokens, setUserAccessTokens] = useState<UserAccessToken[]>([]);
const [showCreateDialog, setShowCreateDialog] = useState<boolean>(false);
useEffect(() => {
listAccessTokens(currentUser.id).then((accessTokens) => {
setUserAccessTokens(accessTokens);
});
}, []);
const handleCreateAccessTokenDialogConfirm = async () => {
const accessTokens = await listAccessTokens(currentUser.id);
setUserAccessTokens(accessTokens);
};
const copyAccessToken = (accessToken: string) => {
copy(accessToken);
toast.success("Access token copied to clipboard");
};
const handleDeleteAccessToken = async (accessToken: string) => {
showCommonDialog({
title: "Delete Access Token",
content: `Are you sure to delete access token \`${getFormatedAccessToken(accessToken)}\`? You cannot undo this action.`,
style: "danger",
onConfirm: async () => {
await userServiceClient.deleteUserAccessToken({
id: currentUser.id,
accessToken: accessToken,
});
setUserAccessTokens(userAccessTokens.filter((token) => token.accessToken !== accessToken));
},
});
};
const getFormatedAccessToken = (accessToken: string) => {
return `${accessToken.slice(0, 4)}****${accessToken.slice(-4)}`;
};
return (
<>
<div className="w-full flex flex-col justify-start items-start space-y-4">
<div className="w-full">
<div className="sm:flex sm:items-center">
<div className="sm:flex-auto">
<p className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-500">Access Tokens</p>
<p className="mt-2 text-sm text-gray-700 dark:text-gray-600">A list of all access tokens for your account.</p>
</div>
<div className="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<Button
variant="outlined"
color="neutral"
onClick={() => {
setShowCreateDialog(true);
}}
>
{t("common.create")}
</Button>
</div>
</div>
<div className="mt-2 flow-root">
<div className="overflow-x-auto">
<div className="inline-block min-w-full py-2 align-middle">
<table className="min-w-full divide-y divide-gray-300 dark:divide-zinc-700">
<thead>
<tr>
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-500">
Token
</th>
<th scope="col" className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 dark:text-gray-500">
Description
</th>
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-500">
Created At
</th>
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-500">
Expires At
</th>
<th scope="col" className="relative py-3.5 pl-3 pr-4">
<span className="sr-only">{t("common.delete")}</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-zinc-800">
{userAccessTokens.map((userAccessToken) => (
<tr key={userAccessToken.accessToken}>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-900 flex flex-row justify-start items-center gap-x-1 dark:text-gray-500">
<span className="font-mono">{getFormatedAccessToken(userAccessToken.accessToken)}</span>
<Button color="neutral" variant="plain" size="sm" onClick={() => copyAccessToken(userAccessToken.accessToken)}>
<Icon.Clipboard className="w-4 h-auto text-gray-500" />
</Button>
</td>
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm text-gray-900 dark:text-gray-500">
{userAccessToken.description}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{userAccessToken.issuedAt?.toLocaleString()}</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{userAccessToken.expiresAt?.toLocaleString() ?? "Never"}
</td>
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm">
<IconButton
color="danger"
variant="plain"
size="sm"
onClick={() => {
handleDeleteAccessToken(userAccessToken.accessToken);
}}
>
<Icon.Trash className="w-4 h-auto" />
</IconButton>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{showCreateDialog && (
<CreateAccessTokenDialog onClose={() => setShowCreateDialog(false)} onConfirm={handleCreateAccessTokenDialogConfirm} />
)}
</>
);
};
export default AccessTokenSection;

View File

@ -1,10 +1,12 @@
import { Button } from "@mui/joy"; import { Button } from "@mui/joy";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
import useUserStore from "../../stores/v1/user"; import useUserStore from "../../stores/v1/user";
import ChangePasswordDialog from "../ChangePasswordDialog"; import ChangePasswordDialog from "../ChangePasswordDialog";
import EditUserinfoDialog from "../EditUserinfoDialog"; import EditUserinfoDialog from "../EditUserinfoDialog";
const AccountSection: React.FC = () => { const AccountSection: React.FC = () => {
const { t } = useTranslation();
const currentUser = useUserStore().getCurrentUser(); const currentUser = useUserStore().getCurrentUser();
const [showEditUserinfoDialog, setShowEditUserinfoDialog] = useState<boolean>(false); const [showEditUserinfoDialog, setShowEditUserinfoDialog] = useState<boolean>(false);
const [showChangePasswordDialog, setShowChangePasswordDialog] = useState<boolean>(false); const [showChangePasswordDialog, setShowChangePasswordDialog] = useState<boolean>(false);
@ -12,19 +14,19 @@ const AccountSection: React.FC = () => {
return ( return (
<> <>
<div className="mx-auto max-w-6xl w-full px-3 md:px-12 py-6 flex flex-col justify-start items-start gap-y-2"> <div className="w-full flex flex-col justify-start items-start gap-y-2">
<p className="text-base font-semibold leading-6 text-gray-900">Account</p> <p className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-500">{t("common.account")}</p>
<p className="flex flex-row justify-start items-center mt-2"> <p className="flex flex-row justify-start items-center mt-2 dark:text-gray-400">
<span className="text-xl">{currentUser.nickname}</span> <span className="text-xl">{currentUser.nickname}</span>
{isAdmin && <span className="ml-2 bg-blue-600 text-white px-2 leading-6 text-sm rounded-full">Admin</span>} {isAdmin && <span className="ml-2 bg-blue-600 text-white px-2 leading-6 text-sm rounded-full">Admin</span>}
</p> </p>
<p className="flex flex-row justify-start items-center"> <p className="flex flex-row justify-start items-center dark:text-gray-400">
<span className="mr-3 text-gray-500 font-mono">Email: </span> <span className="mr-3 text-gray-500">{t("common.email")}: </span>
{currentUser.email} {currentUser.email}
</p> </p>
<div className="flex flex-row justify-start items-center gap-2 mt-2"> <div className="flex flex-row justify-start items-center gap-2 mt-2">
<Button variant="outlined" color="neutral" onClick={() => setShowEditUserinfoDialog(true)}> <Button variant="outlined" color="neutral" onClick={() => setShowEditUserinfoDialog(true)}>
Edit {t("common.edit")}
</Button> </Button>
<Button variant="outlined" color="neutral" onClick={() => setShowChangePasswordDialog(true)}> <Button variant="outlined" color="neutral" onClick={() => setShowChangePasswordDialog(true)}>
Change password Change password

View File

@ -1,9 +1,14 @@
import { Button } from "@mui/joy"; import { Button, IconButton } from "@mui/joy";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import useUserStore from "../../stores/v1/user"; import useUserStore from "../../stores/v1/user";
import { showCommonDialog } from "../Alert";
import CreateUserDialog from "../CreateUserDialog"; import CreateUserDialog from "../CreateUserDialog";
import Icon from "../Icon";
const MemberSection = () => { const MemberSection = () => {
const { t } = useTranslation();
const userStore = useUserStore(); const userStore = useUserStore();
const [showCreateUserDialog, setShowCreateUserDialog] = useState<boolean>(false); const [showCreateUserDialog, setShowCreateUserDialog] = useState<boolean>(false);
const [currentEditingUser, setCurrentEditingUser] = useState<User | undefined>(undefined); const [currentEditingUser, setCurrentEditingUser] = useState<User | undefined>(undefined);
@ -18,64 +23,86 @@ const MemberSection = () => {
setCurrentEditingUser(undefined); setCurrentEditingUser(undefined);
}; };
const handleDeleteUser = async (user: User) => {
showCommonDialog({
title: "Delete User",
content: `Are you sure to delete user \`${user.nickname}\`? You cannot undo this action.`,
style: "danger",
onConfirm: async () => {
try {
await userStore.deleteUser(user.id);
toast.success(`User \`${user.nickname}\` deleted successfully`);
} catch (error: any) {
toast.error(`Failed to delete user \`${user.nickname}\`: ${error.response.data.message}`);
}
},
});
};
return ( return (
<> <>
<div className="mx-auto max-w-6xl w-full px-3 md:px-12 py-6 flex flex-col justify-start items-start space-y-4"> <div className="w-full flex flex-col justify-start items-start space-y-4">
<div className="w-full"> <div className="w-full">
<div className="sm:flex sm:items-center"> <div className="sm:flex sm:items-center">
<div className="sm:flex-auto"> <div className="sm:flex-auto">
<p className="text-base font-semibold leading-6 text-gray-900">Users</p> <p className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-500">{t("user.self")}</p>
<p className="mt-2 text-sm text-gray-700"> <p className="mt-2 text-sm text-gray-700 dark:text-gray-600">
A list of all the users in your workspace including their nickname, email and role. A list of all the users in your workspace including their nickname, email and role.
</p> </p>
</div> </div>
<div className="mt-4 sm:ml-16 sm:mt-0 sm:flex-none"> <div className="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<Button <Button
variant="outlined"
color="neutral"
onClick={() => { onClick={() => {
setShowCreateUserDialog(true); setShowCreateUserDialog(true);
setCurrentEditingUser(undefined); setCurrentEditingUser(undefined);
}} }}
> >
Add user {t("user.action.add-user")}
</Button> </Button>
</div> </div>
</div> </div>
<div className="mt-2 flow-root"> <div className="mt-2 flow-root">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<div className="inline-block min-w-full py-2 align-middle"> <div className="inline-block min-w-full py-2 align-middle">
<table className="min-w-full divide-y divide-gray-300"> <table className="min-w-full divide-y divide-gray-300 dark:divide-zinc-700">
<thead> <thead>
<tr> <tr>
<th scope="col" className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900"> <th scope="col" className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 dark:text-gray-500">
Nickname {t("user.nickname")}
</th> </th>
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"> <th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-500">
Email {t("user.email")}
</th> </th>
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"> <th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-500">
Role {t("user.role")}
</th> </th>
<th scope="col" className="relative py-3.5 pl-3 pr-4"> <th scope="col" className="relative py-3.5 pl-3 pr-4">
<span className="sr-only">Edit</span> <span className="sr-only">{t("common.edit")}</span>
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-200"> <tbody className="divide-y divide-gray-200 dark:divide-zinc-800">
{userList.map((user) => ( {userList.map((user) => (
<tr key={user.email}> <tr key={user.email}>
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm text-gray-900">{user.nickname}</td> <td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm text-gray-900 dark:text-gray-500">{user.nickname}</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{user.email}</td> <td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{user.email}</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{user.role}</td> <td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{user.role}</td>
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium"> <td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm">
<button <IconButton
className="text-indigo-600 hover:text-indigo-900" size="sm"
variant="plain"
onClick={() => { onClick={() => {
setCurrentEditingUser(user); setCurrentEditingUser(user);
setShowCreateUserDialog(true); setShowCreateUserDialog(true);
}} }}
> >
Edit <Icon.PenBox className="w-4 h-auto" />
</button> </IconButton>
<IconButton size="sm" color="danger" variant="plain" onClick={() => handleDeleteUser(user)}>
<Icon.Trash className="w-4 h-auto" />
</IconButton>
</td> </td>
</tr> </tr>
))} ))}

View File

@ -0,0 +1,98 @@
import { Option, Select } from "@mui/joy";
import { useTranslation } from "react-i18next";
import { UserSetting, UserSetting_ColorTheme, UserSetting_Locale } from "@/types/proto/api/v2/user_setting_service";
import useUserStore from "../../stores/v1/user";
import BetaBadge from "../BetaBadge";
const PreferenceSection: React.FC = () => {
const { t } = useTranslation();
const userStore = useUserStore();
const userSetting = userStore.getCurrentUserSetting();
const language = userSetting.locale;
const colorTheme = userSetting.colorTheme;
const languageOptions = [
{
value: UserSetting_Locale.LOCALE_EN,
label: "English",
},
{
value: UserSetting_Locale.LOCALE_ZH,
label: "中文",
},
];
const colorThemeOptions = [
{
value: UserSetting_ColorTheme.COLOR_THEME_SYSTEM,
label: "System",
},
{
value: UserSetting_ColorTheme.COLOR_THEME_LIGHT,
label: "Light",
},
{
value: UserSetting_ColorTheme.COLOR_THEME_DARK,
label: "Dark",
},
];
const handleSelectLanguage = async (locale: UserSetting_Locale) => {
await userStore.updateUserSetting(
{
...userSetting,
locale: locale,
} as UserSetting,
["locale"]
);
};
const handleSelectColorTheme = async (colorTheme: UserSetting_ColorTheme) => {
await userStore.updateUserSetting(
{
...userSetting,
colorTheme: colorTheme,
} as UserSetting,
["color_theme"]
);
};
return (
<>
<div className="w-full flex flex-col justify-start items-start gap-y-2">
<p className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-500">{t("settings.preference.self")}</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">{t("settings.preference.color-theme")}</span>
</div>
<Select defaultValue={colorTheme} onChange={(_, value) => handleSelectColorTheme(value as UserSetting_ColorTheme)}>
{colorThemeOptions.map((option) => {
return (
<Option key={option.value} value={option.value}>
{option.label}
</Option>
);
})}
</Select>
</div>
<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">{t("common.language")}</span>
<BetaBadge />
</div>
<Select defaultValue={language} onChange={(_, value) => handleSelectLanguage(value as UserSetting_Locale)}>
{languageOptions.map((option) => {
return (
<Option key={option.value} value={option.value}>
{option.label}
</Option>
);
})}
</Select>
</div>
</div>
</>
);
};
export default PreferenceSection;

Some files were not shown because too many files have changed in this diff Show More