mirror of
https://github.com/aykhans/slash-e.git
synced 2025-07-06 13:12:36 +00:00
Compare commits
143 Commits
Author | SHA1 | Date | |
---|---|---|---|
015040cc1d | |||
c8869e67c7 | |||
a9ae7d2e96 | |||
db9034ccf9 | |||
4d1705dca5 | |||
3225e7c47b | |||
328397612c | |||
c846cde5b4 | |||
5c2cb99866 | |||
742c7da2eb | |||
88b247410f | |||
01417943fb | |||
09f7c33135 | |||
fe3b78f844 | |||
0fd54426e6 | |||
690e14e4ed | |||
7795b17fd1 | |||
c7dd4dc3eb | |||
6ee6a5166e | |||
8c753e9557 | |||
6126701025 | |||
8ef7d5f0d0 | |||
fa8d2f6639 | |||
8cd976791e | |||
010271c668 | |||
383d4f27f0 | |||
cb9786ef7c | |||
e936bb6f15 | |||
60c440ae10 | |||
fc8808ce04 | |||
e88327f2a3 | |||
159dfc9446 | |||
f78b072bb8 | |||
24fe368974 | |||
46fa546a7d | |||
96f6fa4257 | |||
8436d86661 | |||
a1d1e0f0f2 | |||
0907ad2681 | |||
e1b8bc607b | |||
528ecf72a3 | |||
f0ffe2e419 | |||
0df3164654 | |||
b97fb13929 | |||
3488cd04c0 | |||
07e0bb2d4c | |||
a58ebd27ca | |||
d0a25e3ab2 | |||
92fba82927 | |||
790a8a2e17 | |||
4e3d727b58 | |||
41eea8b571 | |||
8f17abdbf0 | |||
58cb5c7e2e | |||
271c133913 | |||
763205a89b | |||
e82e61d54d | |||
0af4903657 | |||
7f020eade9 | |||
ebe54d1131 | |||
9e8de4644a | |||
a372d07c4b | |||
dd5cce63c5 | |||
3c4155e6a1 | |||
6cb493b4a1 | |||
75d152922e | |||
908f95772d | |||
8992d48b3e | |||
aa247ccef2 | |||
0ba373373d | |||
e843594a02 | |||
032d9c1220 | |||
e5e50b6874 | |||
a7858075d8 | |||
cff6c54b52 | |||
5e6190b181 | |||
b50e809125 | |||
7348f47ef8 | |||
126e4a62f8 | |||
78282dab4d | |||
4a50248fbc | |||
4f0a8cdc0a | |||
a49a708fc5 | |||
bb99341aba | |||
0ce934413a | |||
65e366fdf1 | |||
2fcd496fd2 | |||
7cde25bdb5 | |||
35c396a88f | |||
a970d85e14 | |||
4733e4796d | |||
7c4ccbef3f | |||
b8f31cfd25 | |||
98cb5a2292 | |||
96c1901dce | |||
b807417885 | |||
6495c2081d | |||
0f92ccb22d | |||
bdf7f327d2 | |||
efc3815edf | |||
f5817c575c | |||
40814a801a | |||
e0f805f679 | |||
c4fcfbd6aa | |||
86d17188e1 | |||
88f8c00088 | |||
8612715371 | |||
e91050c803 | |||
ec2ec74e31 | |||
bfb640f201 | |||
34f8a97309 | |||
1c58702716 | |||
bd31c19a15 | |||
7e0ada6161 | |||
b5d6036fcf | |||
0fcee9baf2 | |||
f6fefdb8e6 | |||
0ec06423e5 | |||
8f028e4054 | |||
ae3b632f53 | |||
bafb17015c | |||
d939bb8250 | |||
946548b33a | |||
d97a7e736d | |||
e5d5ba5cbc | |||
ce4232c9f5 | |||
bc6a72561c | |||
b9e5e7f2af | |||
96ab5b226d | |||
9c6f85e938 | |||
f1e3eace1a | |||
6f26523a11 | |||
304a29a18c | |||
3e5fa5573e | |||
93ed3c81ff | |||
0efd495f56 | |||
ae56f6df8c | |||
df51720310 | |||
1194099667 | |||
e936aaced1 | |||
0ee999a30a | |||
1211136037 | |||
73061034b2 |
1
.dockerignore
Normal file
1
.dockerignore
Normal file
@ -0,0 +1 @@
|
|||||||
|
*/*/node_modules
|
4
.github/workflows/backend-tests.yml
vendored
4
.github/workflows/backend-tests.yml
vendored
@ -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
|
||||||
|
19
.github/workflows/extension-test.yml
vendored
19
.github/workflows/extension-test.yml
vendored
@ -8,7 +8,7 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- "extension/**"
|
- "frontend/extension/**"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
eslint-checks:
|
eslint-checks:
|
||||||
@ -18,17 +18,18 @@ jobs:
|
|||||||
- 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: "extension/pnpm-lock.yaml"
|
cache-dependency-path: "frontend/extension/pnpm-lock.yaml"
|
||||||
- run: pnpm install
|
- run: pnpm install
|
||||||
working-directory: extension
|
working-directory: frontend/extension
|
||||||
|
- run: pnpm type-gen
|
||||||
|
working-directory: frontend/extension
|
||||||
- name: Run eslint check
|
- name: Run eslint check
|
||||||
run: pnpm lint
|
run: pnpm lint
|
||||||
working-directory: extension
|
working-directory: frontend/extension
|
||||||
|
|
||||||
extension-build:
|
extension-build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@ -41,9 +42,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: "18"
|
node-version: "18"
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
cache-dependency-path: "extension/pnpm-lock.yaml"
|
cache-dependency-path: "frontend/extension/pnpm-lock.yaml"
|
||||||
- run: pnpm install
|
- run: pnpm install
|
||||||
working-directory: extension
|
working-directory: frontend/extension
|
||||||
|
- run: pnpm type-gen
|
||||||
|
working-directory: frontend/extension
|
||||||
- name: Run extension build
|
- name: Run extension build
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
working-directory: extension
|
working-directory: frontend/extension
|
||||||
|
19
.github/workflows/frontend-test.yml
vendored
19
.github/workflows/frontend-test.yml
vendored
@ -8,7 +8,7 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- "web/**"
|
- "frontend/web/**"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
eslint-checks:
|
eslint-checks:
|
||||||
@ -18,17 +18,18 @@ jobs:
|
|||||||
- 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
|
||||||
|
- run: pnpm type-gen
|
||||||
|
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
|
||||||
@ -41,9 +42,11 @@ 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
|
||||||
|
- run: pnpm type-gen
|
||||||
|
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
5
.gitignore
vendored
@ -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
|
||||||
|
@ -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)?'
|
||||||
|
16
Dockerfile
16
Dockerfile
@ -1,26 +1,26 @@
|
|||||||
# 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 . .
|
||||||
|
|
||||||
RUN corepack enable && pnpm i --frozen-lockfile
|
WORKDIR /frontend-build/frontend/web
|
||||||
|
|
||||||
COPY ./web/ .
|
RUN corepack enable && pnpm i --frozen-lockfile && pnpm type-gen
|
||||||
|
|
||||||
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/frontend/web/dist ./server/dist
|
||||||
|
|
||||||
RUN CGO_ENABLED=0 go build -o slash ./cmd/slash/main.go
|
RUN CGO_ENABLED=0 go build -o slash ./bin/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
|
||||||
|
28
README.md
28
README.md
@ -4,10 +4,13 @@
|
|||||||
|
|
||||||
**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.
|
||||||
|
|
||||||
|
🧩 Browser extension(v1.0.0) now available! - [Chrome Web Store](https://chrome.google.com/webstore/detail/slash/ebaiehmkammnacjadffpicipfckgeobg), [Firefox Add-on](https://addons.mozilla.org/firefox/addon/your-slash/)
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||

|

|
||||||
@ -15,8 +18,9 @@
|
|||||||
## 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 +30,19 @@ 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Learn more in [The Browser Extension of Slash](https://github.com/boojack/slash/blob/main/docs/install-browser-extension.md).
|
||||||
|
|
||||||
|
### 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).
|
||||||
|
|
||||||
|
### Firefox
|
||||||
|
|
||||||
|
For Firefox, you can install the extension from the [Firefox Add-ons](https://addons.mozilla.org/firefox/addon/your-slash/).
|
||||||
|
@ -31,8 +31,8 @@ type ClaimsMessage struct {
|
|||||||
|
|
||||||
// GenerateAccessToken generates an access token.
|
// GenerateAccessToken generates an access token.
|
||||||
// username is the email of the user.
|
// username is the email of the user.
|
||||||
func GenerateAccessToken(username string, userID int32, expirationTime time.Time, secret string) (string, error) {
|
func GenerateAccessToken(username string, userID int32, expirationTime time.Time, secret []byte) (string, error) {
|
||||||
return generateToken(username, userID, AccessTokenAudienceName, expirationTime, []byte(secret))
|
return generateToken(username, userID, AccessTokenAudienceName, expirationTime, secret)
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateToken generates a jwt token.
|
// generateToken generates a jwt token.
|
||||||
@ -43,7 +43,7 @@ func generateToken(username string, userID int32, audience string, expirationTim
|
|||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
Subject: fmt.Sprint(userID),
|
Subject: fmt.Sprint(userID),
|
||||||
}
|
}
|
||||||
if expirationTime.After(time.Now()) {
|
if !expirationTime.IsZero() {
|
||||||
registeredClaims.ExpiresAt = jwt.NewNumericDate(expirationTime)
|
registeredClaims.ExpiresAt = jwt.NewNumericDate(expirationTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,10 +6,12 @@ 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/server/metric"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ReferenceInfo struct {
|
type ReferenceInfo struct {
|
||||||
@ -77,6 +79,7 @@ func (s *APIV1Service) registerAnalyticsRoutes(g *echo.Group) {
|
|||||||
browserMap[browserName]++
|
browserMap[browserName]++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
metric.Enqueue("shortcut analytics")
|
||||||
return c.JSON(http.StatusOK, &AnalysisData{
|
return c.JSON(http.StatusOK, &AnalysisData{
|
||||||
ReferenceData: mapToReferenceInfoSlice(referenceMap),
|
ReferenceData: mapToReferenceInfoSlice(referenceMap),
|
||||||
DeviceData: mapToDeviceInfoSlice(deviceMap),
|
DeviceData: mapToDeviceInfoSlice(deviceMap),
|
||||||
|
@ -7,12 +7,15 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/boojack/slash/api/auth"
|
|
||||||
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"
|
||||||
"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/metric"
|
||||||
|
"github.com/boojack/slash/server/service/license"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SignInRequest struct {
|
type SignInRequest struct {
|
||||||
@ -51,7 +54,7 @@ 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")
|
||||||
}
|
}
|
||||||
|
|
||||||
accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, time.Now().Add(auth.AccessTokenDuration), secret)
|
accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, time.Now().Add(auth.AccessTokenDuration), []byte(secret))
|
||||||
if err != nil {
|
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)
|
||||||
}
|
}
|
||||||
@ -61,21 +64,32 @@ func (s *APIV1Service) registerAuthRoutes(g *echo.Group, secret string) {
|
|||||||
|
|
||||||
cookieExp := time.Now().Add(auth.CookieExpDuration)
|
cookieExp := time.Now().Add(auth.CookieExpDuration)
|
||||||
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
|
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
|
||||||
|
metric.Enqueue("user sign in")
|
||||||
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)
|
||||||
@ -107,7 +121,7 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, time.Now().Add(auth.AccessTokenDuration), secret)
|
accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, time.Now().Add(auth.AccessTokenDuration), []byte(secret))
|
||||||
if err != nil {
|
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)
|
||||||
}
|
}
|
||||||
@ -117,11 +131,37 @@ func (s *APIV1Service) registerAuthRoutes(g *echo.Group, secret string) {
|
|||||||
|
|
||||||
cookieExp := time.Now().Add(auth.CookieExpDuration)
|
cookieExp := time.Now().Add(auth.CookieExpDuration)
|
||||||
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
|
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
|
||||||
|
metric.Enqueue("user sign up")
|
||||||
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
|
||||||
})
|
})
|
||||||
@ -140,8 +180,8 @@ func (s *APIV1Service) UpsertAccessTokenToStore(ctx context.Context, user *store
|
|||||||
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
|
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
|
||||||
UserId: user.ID,
|
UserId: user.ID,
|
||||||
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
|
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
|
||||||
Value: &storepb.UserSetting_AccessTokensUserSetting{
|
Value: &storepb.UserSetting_AccessTokens{
|
||||||
AccessTokensUserSetting: &storepb.AccessTokensUserSetting{
|
AccessTokens: &storepb.AccessTokensUserSetting{
|
||||||
AccessTokens: userAccessTokens,
|
AccessTokens: userAccessTokens,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -5,19 +5,20 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v4"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
"github.com/boojack/slash/api/auth"
|
"github.com/boojack/slash/api/auth"
|
||||||
"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/golang-jwt/jwt/v4"
|
|
||||||
"github.com/labstack/echo/v4"
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
func extractTokenFromHeader(c echo.Context) (string, error) {
|
func extractTokenFromHeader(c echo.Context) (string, error) {
|
||||||
@ -35,25 +36,16 @@ 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 {
|
||||||
return accessToken
|
accessToken = cookie.Value
|
||||||
}
|
|
||||||
|
|
||||||
func audienceContains(audience jwt.ClaimStrings, token string) bool {
|
|
||||||
for _, v := range audience {
|
|
||||||
if v == token {
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return accessToken
|
||||||
}
|
}
|
||||||
|
|
||||||
// JWTMiddleware validates the access token.
|
// JWTMiddleware validates the access token.
|
||||||
@ -68,45 +60,25 @@ func JWTMiddleware(s *APIV1Service, next echo.HandlerFunc, secret string) echo.H
|
|||||||
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 := &auth.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 {
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, errors.Wrap(err, "Invalid or expired access token"))
|
return echo.NewHTTPError(http.StatusUnauthorized, "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.
|
|
||||||
userID, err := util.ConvertStringToInt32(claims.Subject)
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Malformed ID in the token.").WithInternal(err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
accessTokens, err := s.Store.GetUserAccessTokens(ctx, userID)
|
accessTokens, err := s.Store.GetUserAccessTokens(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get user access tokens.").WithInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get user access tokens.").WithInternal(err)
|
||||||
}
|
}
|
||||||
if !validateAccessToken(token, accessTokens) {
|
if !validateAccessToken(accessToken, accessTokens) {
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid access token.")
|
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid access token.")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -122,11 +94,35 @@ func JWTMiddleware(s *APIV1Service, next echo.HandlerFunc, secret string) echo.H
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
func validateAccessToken(accessTokenString string, userAccessTokens []*storepb.AccessTokensUserSetting_AccessToken) bool {
|
||||||
for _, userAccessToken := range userAccessTokens {
|
for _, userAccessToken := range userAccessTokens {
|
||||||
if accessTokenString == userAccessToken.AccessToken {
|
if accessTokenString == userAccessToken.AccessToken {
|
||||||
|
@ -8,10 +8,12 @@ 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/server/metric"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *APIV1Service) registerRedirectorRoutes(g *echo.Group) {
|
func (s *APIV1Service) registerRedirectorRoutes(g *echo.Group) {
|
||||||
@ -29,10 +31,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")
|
||||||
}
|
}
|
||||||
@ -45,6 +47,7 @@ func (s *APIV1Service) registerRedirectorRoutes(g *echo.Group) {
|
|||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create activity, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create activity, err: %s", err)).SetInternal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
metric.Enqueue("shortcut redirect")
|
||||||
return redirectToShortcut(c, shortcut)
|
return redirectToShortcut(c, shortcut)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -7,11 +7,13 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/boojack/slash/internal/util"
|
|
||||||
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"
|
||||||
|
|
||||||
|
"github.com/boojack/slash/internal/util"
|
||||||
|
storepb "github.com/boojack/slash/proto/gen/store"
|
||||||
|
"github.com/boojack/slash/server/metric"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Visibility is the type of a shortcut visibility.
|
// Visibility is the type of a shortcut visibility.
|
||||||
@ -81,7 +83,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 +92,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)
|
||||||
}
|
}
|
||||||
@ -116,6 +122,7 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
||||||
}
|
}
|
||||||
|
metric.Enqueue("shortcut create")
|
||||||
return c.JSON(http.StatusOK, shortcutMessage)
|
return c.JSON(http.StatusOK, shortcutMessage)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -125,7 +132,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 +160,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 +199,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 +266,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 +352,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:
|
||||||
|
@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
@ -6,10 +6,14 @@ 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/metric"
|
||||||
|
"github.com/boojack/slash/server/service/license"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -60,13 +64,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 +87,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 +104,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)
|
||||||
@ -124,6 +138,7 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
userMessage := convertUserFromStore(user)
|
userMessage := convertUserFromStore(user)
|
||||||
|
metric.Enqueue("user create")
|
||||||
return c.JSON(http.StatusOK, userMessage)
|
return c.JSON(http.StatusOK, userMessage)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -144,7 +159,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 +197,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 +269,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")
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
19
api/v1/v1.go
19
api/v1/v1.go
@ -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)
|
||||||
|
@ -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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -5,35 +5,26 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"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"
|
|
||||||
"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{}
|
|
||||||
|
|
||||||
// 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.
|
||||||
@ -63,31 +54,35 @@ func (in *GRPCAuthInterceptor) AuthenticationInterceptor(ctx context.Context, re
|
|||||||
|
|
||||||
userID, err := in.authenticate(ctx, accessToken)
|
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{
|
||||||
userAccessTokens, err := in.Store.GetUserAccessTokens(ctx, userID)
|
ID: &userID,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "failed to get user access tokens")
|
return nil, errors.Wrap(err, "failed to get user")
|
||||||
}
|
}
|
||||||
if !validateAccessToken(accessToken, userAccessTokens) {
|
if user == nil {
|
||||||
return nil, status.Errorf(codes.Unauthenticated, "invalid access token")
|
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 := &auth.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)
|
||||||
}
|
}
|
||||||
@ -126,6 +121,14 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
27
api/v2/acl_config.go
Normal file
27
api/v2/acl_config.go
Normal 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]
|
||||||
|
}
|
@ -3,11 +3,14 @@ package v2
|
|||||||
import (
|
import (
|
||||||
"context"
|
"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"
|
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
||||||
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"
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ShortcutService struct {
|
type ShortcutService struct {
|
||||||
@ -26,7 +29,7 @@ func NewShortcutService(secret string, store *store.Store) *ShortcutService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *ShortcutService) ListShortcuts(ctx context.Context, _ *apiv2pb.ListShortcutsRequest) (*apiv2pb.ListShortcutsResponse, error) {
|
func (s *ShortcutService) ListShortcuts(ctx context.Context, _ *apiv2pb.ListShortcutsRequest) (*apiv2pb.ListShortcutsResponse, error) {
|
||||||
userID := ctx.Value(UserIDContextKey).(int32)
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
find := &store.FindShortcut{}
|
find := &store.FindShortcut{}
|
||||||
find.VisibilityList = []store.Visibility{store.VisibilityWorkspace, store.VisibilityPublic}
|
find.VisibilityList = []store.Visibility{store.VisibilityWorkspace, store.VisibilityPublic}
|
||||||
visibleShortcutList, err := s.Store.ListShortcuts(ctx, find)
|
visibleShortcutList, err := s.Store.ListShortcuts(ctx, find)
|
||||||
@ -64,7 +67,7 @@ func (s *ShortcutService) GetShortcut(ctx context.Context, request *apiv2pb.GetS
|
|||||||
return nil, status.Errorf(codes.NotFound, "shortcut not found")
|
return nil, status.Errorf(codes.NotFound, "shortcut not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
userID := ctx.Value(UserIDContextKey).(int32)
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
if shortcut.Visibility == storepb.Visibility_PRIVATE && shortcut.CreatorId != userID {
|
if shortcut.Visibility == storepb.Visibility_PRIVATE && shortcut.CreatorId != userID {
|
||||||
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
}
|
}
|
||||||
@ -75,6 +78,91 @@ func (s *ShortcutService) GetShortcut(ctx context.Context, request *apiv2pb.GetS
|
|||||||
return response, nil
|
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 {
|
func convertShortcutFromStorepb(shortcut *storepb.Shortcut) *apiv2pb.Shortcut {
|
||||||
return &apiv2pb.Shortcut{
|
return &apiv2pb.Shortcut{
|
||||||
Id: shortcut.Id,
|
Id: shortcut.Id,
|
||||||
|
50
api/v2/subscription_service.go
Normal file
50
api/v2/subscription_service.go
Normal 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
|
||||||
|
}
|
@ -2,34 +2,56 @@ package v2
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"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/store"
|
|
||||||
"github.com/golang-jwt/jwt/v4"
|
"github.com/golang-jwt/jwt/v4"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
"golang.org/x/exp/slices"
|
"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"
|
"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
|
||||||
|
|
||||||
Secret string
|
Secret string
|
||||||
Store *store.Store
|
Store *store.Store
|
||||||
|
LicenseService *license.LicenseService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewUserService creates a new UserService.
|
// NewUserService creates a new UserService.
|
||||||
func NewUserService(secret string, store *store.Store) *UserService {
|
func NewUserService(secret string, store *store.Store, licenseService *license.LicenseService) *UserService {
|
||||||
return &UserService{
|
return &UserService{
|
||||||
Secret: secret,
|
Secret: secret,
|
||||||
Store: store,
|
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,
|
||||||
@ -48,8 +70,83 @@ 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) {
|
func (s *UserService) ListUserAccessTokens(ctx context.Context, request *apiv2pb.ListUserAccessTokensRequest) (*apiv2pb.ListUserAccessTokensResponse, error) {
|
||||||
userID := ctx.Value(UserIDContextKey).(int32)
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
if userID != request.Id {
|
if userID != request.Id {
|
||||||
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
}
|
}
|
||||||
@ -100,7 +197,7 @@ func (s *UserService) ListUserAccessTokens(ctx context.Context, request *apiv2pb
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) CreateUserAccessToken(ctx context.Context, request *apiv2pb.CreateUserAccessTokenRequest) (*apiv2pb.CreateUserAccessTokenResponse, error) {
|
func (s *UserService) CreateUserAccessToken(ctx context.Context, request *apiv2pb.CreateUserAccessTokenRequest) (*apiv2pb.CreateUserAccessTokenResponse, error) {
|
||||||
userID := ctx.Value(UserIDContextKey).(int32)
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
if userID != request.Id {
|
if userID != request.Id {
|
||||||
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
}
|
}
|
||||||
@ -115,7 +212,11 @@ func (s *UserService) CreateUserAccessToken(ctx context.Context, request *apiv2p
|
|||||||
return nil, status.Errorf(codes.NotFound, "user not found")
|
return nil, status.Errorf(codes.NotFound, "user not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, request.UserAccessToken.ExpiresAt.AsTime(), s.Secret)
|
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 {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "failed to generate access token: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to generate access token: %v", err)
|
||||||
}
|
}
|
||||||
@ -137,13 +238,13 @@ func (s *UserService) CreateUserAccessToken(ctx context.Context, request *apiv2p
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Upsert the access token to user setting store.
|
// Upsert the access token to user setting store.
|
||||||
if err := s.UpsertAccessTokenToStore(ctx, user, accessToken, request.UserAccessToken.Description); err != nil {
|
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)
|
return nil, status.Errorf(codes.Internal, "failed to upsert access token to store: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
userAccessToken := &apiv2pb.UserAccessToken{
|
userAccessToken := &apiv2pb.UserAccessToken{
|
||||||
AccessToken: accessToken,
|
AccessToken: accessToken,
|
||||||
Description: request.UserAccessToken.Description,
|
Description: request.Description,
|
||||||
IssuedAt: timestamppb.New(claims.IssuedAt.Time),
|
IssuedAt: timestamppb.New(claims.IssuedAt.Time),
|
||||||
}
|
}
|
||||||
if claims.ExpiresAt != nil {
|
if claims.ExpiresAt != nil {
|
||||||
@ -156,7 +257,7 @@ func (s *UserService) CreateUserAccessToken(ctx context.Context, request *apiv2p
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) DeleteUserAccessToken(ctx context.Context, request *apiv2pb.DeleteUserAccessTokenRequest) (*apiv2pb.DeleteUserAccessTokenResponse, error) {
|
func (s *UserService) DeleteUserAccessToken(ctx context.Context, request *apiv2pb.DeleteUserAccessTokenRequest) (*apiv2pb.DeleteUserAccessTokenResponse, error) {
|
||||||
userID := ctx.Value(UserIDContextKey).(int32)
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
if userID != request.Id {
|
if userID != request.Id {
|
||||||
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
}
|
}
|
||||||
@ -175,8 +276,8 @@ func (s *UserService) DeleteUserAccessToken(ctx context.Context, request *apiv2p
|
|||||||
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
|
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
|
||||||
UserId: userID,
|
UserId: userID,
|
||||||
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
|
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
|
||||||
Value: &storepb.UserSetting_AccessTokensUserSetting{
|
Value: &storepb.UserSetting_AccessTokens{
|
||||||
AccessTokensUserSetting: &storepb.AccessTokensUserSetting{
|
AccessTokens: &storepb.AccessTokensUserSetting{
|
||||||
AccessTokens: updatedUserAccessTokens,
|
AccessTokens: updatedUserAccessTokens,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -200,8 +301,8 @@ func (s *UserService) UpsertAccessTokenToStore(ctx context.Context, user *store.
|
|||||||
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
|
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
|
||||||
UserId: user.ID,
|
UserId: user.ID,
|
||||||
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
|
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
|
||||||
Value: &storepb.UserSetting_AccessTokensUserSetting{
|
Value: &storepb.UserSetting_AccessTokens{
|
||||||
AccessTokensUserSetting: &storepb.AccessTokensUserSetting{
|
AccessTokens: &storepb.AccessTokensUserSetting{
|
||||||
AccessTokens: userAccessTokens,
|
AccessTokens: userAccessTokens,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
148
api/v2/user_setting_service.go
Normal file
148
api/v2/user_setting_service.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
45
api/v2/v2.go
45
api/v2/v2.go
@ -4,38 +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(secret, 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))
|
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,
|
||||||
}
|
}
|
||||||
@ -59,13 +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 {
|
if err := apiv2pb.RegisterShortcutServiceHandler(context.Background(), gwMux, conn); err != nil {
|
||||||
return err
|
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
|
||||||
}
|
}
|
||||||
|
153
api/v2/workspace_service.go
Normal file
153
api/v2/workspace_service.go
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
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 _, 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
|
||||||
|
}
|
@ -10,10 +10,13 @@ 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/metric"
|
||||||
|
"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,31 +26,34 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nolint
|
||||||
|
metric.NewMetricClient(s.Secret, *serverProfile)
|
||||||
|
|
||||||
c := make(chan os.Signal, 1)
|
c := make(chan os.Signal, 1)
|
||||||
// Trigger graceful shutdown on SIGINT or SIGTERM.
|
// Trigger graceful shutdown on SIGINT or SIGTERM.
|
||||||
// The default signal sent by the `kill` command is SIGTERM,
|
// The default signal sent by the `kill` command is SIGTERM,
|
||||||
@ -55,16 +61,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 +82,7 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func Execute() error {
|
func Execute() error {
|
||||||
|
defer log.Sync()
|
||||||
return rootCmd.Execute()
|
return rootCmd.Execute()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,18 +114,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
13
docker-compose.yml
Normal 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:
|
@ -4,6 +4,12 @@ Slash provides a browser extension to help you use your shortcuts in the search
|
|||||||
|
|
||||||
## How to use
|
## 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, you can install the extension from the [Firefox Add-ons](https://addons.mozilla.org/en-US/firefox/addon/your-slash/).
|
||||||
|
|
||||||
### Generate an access token
|
### Generate an access token
|
||||||
|
|
||||||
1. Go to your Slash instance and sign in with your account.
|
1. Go to your Slash instance and sign in with your account.
|
||||||
@ -16,14 +22,6 @@ Slash provides a browser extension to help you use your shortcuts in the search
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
### Install the extension
|
|
||||||
|
|
||||||
> **Note**: The extension is not published to the Chrome Web Store yet. You can install it from the source code.
|
|
||||||
|
|
||||||
For Chromuim based browsers, you can download the packed extension from the [resources](https://github.com/boojack/slash/tree/main/extension/resources).
|
|
||||||
|
|
||||||
For Firefox, we don't support the Firefox Add-ons platform yet. And we are working on it.
|
|
||||||
|
|
||||||
### Configure the extension
|
### Configure the extension
|
||||||
|
|
||||||
1. Click on the extension icon and click on the "Settings" button.
|
1. Click on the extension icon and click on the "Settings" button.
|
||||||
|
@ -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
|
||||||
|
```
|
||||||
|
Binary file not shown.
Before Width: | Height: | Size: 83 KiB |
Binary file not shown.
@ -1,14 +0,0 @@
|
|||||||
import { Storage } from "@plasmohq/storage";
|
|
||||||
import axios from "axios";
|
|
||||||
|
|
||||||
const storage = new Storage();
|
|
||||||
|
|
||||||
export const getUrlFavicon = async (url: string) => {
|
|
||||||
const domain = await storage.getItem<string>("domain");
|
|
||||||
const accessToken = await storage.getItem<string>("access_token");
|
|
||||||
return axios.get<string>(`${domain}/api/v1/url/favicon?url=${url}`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
@ -1,5 +0,0 @@
|
|||||||
import { isNull, isUndefined } from "lodash-es";
|
|
||||||
|
|
||||||
export const isNullorUndefined = (value: any) => {
|
|
||||||
return isNull(value) || isUndefined(value);
|
|
||||||
};
|
|
@ -1,89 +0,0 @@
|
|||||||
import { Button, Input } 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 "./style.css";
|
|
||||||
|
|
||||||
interface SettingState {
|
|
||||||
domain: string;
|
|
||||||
accessToken: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const IndexOptions = () => {
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
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");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="w-full">
|
|
||||||
<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 font-mono">
|
|
||||||
<Icon.CircleSlash className="w-8 h-auto mr-2 text-gray-500" />
|
|
||||||
<span className="text-lg">Slash</span>
|
|
||||||
<span className="mx-2 text-gray-400">/</span>
|
|
||||||
<span className="text-lg">Setting</span>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="w-full flex flex-col justify-start items-start mb-4">
|
|
||||||
<span className="mb-2 text-base">Domain</span>
|
|
||||||
<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">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">
|
|
||||||
<Button onClick={handleSaveSetting}>Save</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Toaster position="top-center" />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default IndexOptions;
|
|
@ -1,78 +0,0 @@
|
|||||||
import { Button } from "@mui/joy";
|
|
||||||
import { useStorage } from "@plasmohq/storage/hook";
|
|
||||||
import { Toaster } from "react-hot-toast";
|
|
||||||
import { Shortcut } from "@/types/proto/api/v2/shortcut_service_pb";
|
|
||||||
import Icon from "./components/Icon";
|
|
||||||
import PullShortcutsButton from "./components/PullShortcutsButton";
|
|
||||||
import ShortcutsContainer from "./components/ShortcutsContainer";
|
|
||||||
import "./style.css";
|
|
||||||
|
|
||||||
const IndexPopup = () => {
|
|
||||||
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();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="w-full min-w-[480px] p-4">
|
|
||||||
<div className="w-full flex flex-row justify-between items-center text-sm">
|
|
||||||
<div className="flex flex-row justify-start items-center font-mono">
|
|
||||||
<Icon.CircleSlash className="w-5 h-auto mr-1 text-gray-500 -mt-0.5" />
|
|
||||||
<span className="font-mono">Slash</span>
|
|
||||||
{isInitialized && (
|
|
||||||
<>
|
|
||||||
<span className="mx-1 text-gray-400">/</span>
|
|
||||||
<span>Shortcuts</span>
|
|
||||||
<span className="mr-1 text-gray-500">({shortcuts.length})</span>
|
|
||||||
<PullShortcutsButton />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Button size="sm" variant="plain" color="neutral" onClick={handleSettingButtonClick}>
|
|
||||||
<Icon.Settings className="w-5 h-auto" />
|
|
||||||
</Button>
|
|
||||||
</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>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<div className="w-full flex flex-col justify-start items-center">
|
|
||||||
<p>No domain and access token found.</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">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>
|
|
||||||
|
|
||||||
<Toaster position="top-right" />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default IndexPopup;
|
|
@ -1,41 +0,0 @@
|
|||||||
import { create } from "zustand";
|
|
||||||
import { persist } from "zustand/middleware";
|
|
||||||
import { getUrlFavicon } from "../helpers/api";
|
|
||||||
|
|
||||||
interface FaviconState {
|
|
||||||
cache: {
|
|
||||||
[key: string]: string;
|
|
||||||
};
|
|
||||||
getOrFetchUrlFavicon: (url: string) => Promise<string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const useFaviconStore = create<FaviconState>()(
|
|
||||||
persist(
|
|
||||||
(set, get) => ({
|
|
||||||
cache: {},
|
|
||||||
getOrFetchUrlFavicon: async (url: string) => {
|
|
||||||
const cache = get().cache;
|
|
||||||
if (cache[url]) {
|
|
||||||
return cache[url];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { data: favicon } = await getUrlFavicon(url);
|
|
||||||
if (favicon) {
|
|
||||||
cache[url] = favicon;
|
|
||||||
set(cache);
|
|
||||||
return favicon;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// do nothing
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
name: "favicon_cache",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
export default useFaviconStore;
|
|
25
extension/src/types/proto/api/v2/common_pb.d.ts
vendored
25
extension/src/types/proto/api/v2/common_pb.d.ts
vendored
@ -1,25 +0,0 @@
|
|||||||
// @generated by protoc-gen-es v1.3.0
|
|
||||||
// @generated from file api/v2/common.proto (package slash.api.v2, syntax proto3)
|
|
||||||
/* eslint-disable */
|
|
||||||
// @ts-nocheck
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum slash.api.v2.RowStatus
|
|
||||||
*/
|
|
||||||
export declare enum RowStatus {
|
|
||||||
/**
|
|
||||||
* @generated from enum value: ROW_STATUS_UNSPECIFIED = 0;
|
|
||||||
*/
|
|
||||||
ROW_STATUS_UNSPECIFIED = 0,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum value: NORMAL = 1;
|
|
||||||
*/
|
|
||||||
NORMAL = 1,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum value: ARCHIVED = 2;
|
|
||||||
*/
|
|
||||||
ARCHIVED = 2,
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
|||||||
// @generated by protoc-gen-es v1.3.0
|
|
||||||
// @generated from file api/v2/common.proto (package slash.api.v2, syntax proto3)
|
|
||||||
/* eslint-disable */
|
|
||||||
// @ts-nocheck
|
|
||||||
|
|
||||||
import { proto3 } from "@bufbuild/protobuf";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum slash.api.v2.RowStatus
|
|
||||||
*/
|
|
||||||
export const RowStatus = proto3.makeEnum(
|
|
||||||
"slash.api.v2.RowStatus",
|
|
||||||
[
|
|
||||||
{no: 0, name: "ROW_STATUS_UNSPECIFIED"},
|
|
||||||
{no: 1, name: "NORMAL"},
|
|
||||||
{no: 2, name: "ARCHIVED"},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
@ -1,238 +0,0 @@
|
|||||||
// @generated by protoc-gen-es v1.3.0
|
|
||||||
// @generated from file api/v2/shortcut_service.proto (package slash.api.v2, syntax proto3)
|
|
||||||
/* eslint-disable */
|
|
||||||
// @ts-nocheck
|
|
||||||
|
|
||||||
import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf";
|
|
||||||
import { Message, proto3 } from "@bufbuild/protobuf";
|
|
||||||
import type { RowStatus } from "./common_pb.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum slash.api.v2.Visibility
|
|
||||||
*/
|
|
||||||
export declare enum Visibility {
|
|
||||||
/**
|
|
||||||
* @generated from enum value: VISIBILITY_UNSPECIFIED = 0;
|
|
||||||
*/
|
|
||||||
VISIBILITY_UNSPECIFIED = 0,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum value: PRIVATE = 1;
|
|
||||||
*/
|
|
||||||
PRIVATE = 1,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum value: WORKSPACE = 2;
|
|
||||||
*/
|
|
||||||
WORKSPACE = 2,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum value: PUBLIC = 3;
|
|
||||||
*/
|
|
||||||
PUBLIC = 3,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.Shortcut
|
|
||||||
*/
|
|
||||||
export declare class Shortcut extends Message<Shortcut> {
|
|
||||||
/**
|
|
||||||
* @generated from field: int32 id = 1;
|
|
||||||
*/
|
|
||||||
id: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: int32 creator_id = 2;
|
|
||||||
*/
|
|
||||||
creatorId: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: int64 created_ts = 3;
|
|
||||||
*/
|
|
||||||
createdTs: bigint;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: int64 updated_ts = 4;
|
|
||||||
*/
|
|
||||||
updatedTs: bigint;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: slash.api.v2.RowStatus row_status = 5;
|
|
||||||
*/
|
|
||||||
rowStatus: RowStatus;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: string name = 6;
|
|
||||||
*/
|
|
||||||
name: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: string link = 7;
|
|
||||||
*/
|
|
||||||
link: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: string title = 8;
|
|
||||||
*/
|
|
||||||
title: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: repeated string tags = 9;
|
|
||||||
*/
|
|
||||||
tags: string[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: string description = 10;
|
|
||||||
*/
|
|
||||||
description: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: slash.api.v2.Visibility visibility = 11;
|
|
||||||
*/
|
|
||||||
visibility: Visibility;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: slash.api.v2.OpenGraphMetadata og_metadata = 12;
|
|
||||||
*/
|
|
||||||
ogMetadata?: OpenGraphMetadata;
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<Shortcut>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.api.v2.Shortcut";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): Shortcut;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): Shortcut;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): Shortcut;
|
|
||||||
|
|
||||||
static equals(a: Shortcut | PlainMessage<Shortcut> | undefined, b: Shortcut | PlainMessage<Shortcut> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.OpenGraphMetadata
|
|
||||||
*/
|
|
||||||
export declare class OpenGraphMetadata extends Message<OpenGraphMetadata> {
|
|
||||||
/**
|
|
||||||
* @generated from field: string title = 1;
|
|
||||||
*/
|
|
||||||
title: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: string description = 2;
|
|
||||||
*/
|
|
||||||
description: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: string image = 3;
|
|
||||||
*/
|
|
||||||
image: string;
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<OpenGraphMetadata>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.api.v2.OpenGraphMetadata";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): OpenGraphMetadata;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): OpenGraphMetadata;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): OpenGraphMetadata;
|
|
||||||
|
|
||||||
static equals(a: OpenGraphMetadata | PlainMessage<OpenGraphMetadata> | undefined, b: OpenGraphMetadata | PlainMessage<OpenGraphMetadata> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.ListShortcutsRequest
|
|
||||||
*/
|
|
||||||
export declare class ListShortcutsRequest extends Message<ListShortcutsRequest> {
|
|
||||||
constructor(data?: PartialMessage<ListShortcutsRequest>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.api.v2.ListShortcutsRequest";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): ListShortcutsRequest;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): ListShortcutsRequest;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): ListShortcutsRequest;
|
|
||||||
|
|
||||||
static equals(a: ListShortcutsRequest | PlainMessage<ListShortcutsRequest> | undefined, b: ListShortcutsRequest | PlainMessage<ListShortcutsRequest> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.ListShortcutsResponse
|
|
||||||
*/
|
|
||||||
export declare class ListShortcutsResponse extends Message<ListShortcutsResponse> {
|
|
||||||
/**
|
|
||||||
* @generated from field: repeated slash.api.v2.Shortcut shortcuts = 1;
|
|
||||||
*/
|
|
||||||
shortcuts: Shortcut[];
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<ListShortcutsResponse>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.api.v2.ListShortcutsResponse";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): ListShortcutsResponse;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): ListShortcutsResponse;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): ListShortcutsResponse;
|
|
||||||
|
|
||||||
static equals(a: ListShortcutsResponse | PlainMessage<ListShortcutsResponse> | undefined, b: ListShortcutsResponse | PlainMessage<ListShortcutsResponse> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.GetShortcutRequest
|
|
||||||
*/
|
|
||||||
export declare class GetShortcutRequest extends Message<GetShortcutRequest> {
|
|
||||||
/**
|
|
||||||
* @generated from field: string name = 1;
|
|
||||||
*/
|
|
||||||
name: string;
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<GetShortcutRequest>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.api.v2.GetShortcutRequest";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): GetShortcutRequest;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): GetShortcutRequest;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): GetShortcutRequest;
|
|
||||||
|
|
||||||
static equals(a: GetShortcutRequest | PlainMessage<GetShortcutRequest> | undefined, b: GetShortcutRequest | PlainMessage<GetShortcutRequest> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.GetShortcutResponse
|
|
||||||
*/
|
|
||||||
export declare class GetShortcutResponse extends Message<GetShortcutResponse> {
|
|
||||||
/**
|
|
||||||
* @generated from field: slash.api.v2.Shortcut shortcut = 1;
|
|
||||||
*/
|
|
||||||
shortcut?: Shortcut;
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<GetShortcutResponse>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.api.v2.GetShortcutResponse";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): GetShortcutResponse;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): GetShortcutResponse;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): GetShortcutResponse;
|
|
||||||
|
|
||||||
static equals(a: GetShortcutResponse | PlainMessage<GetShortcutResponse> | undefined, b: GetShortcutResponse | PlainMessage<GetShortcutResponse> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
|||||||
// @generated by protoc-gen-es v1.3.0
|
|
||||||
// @generated from file api/v2/shortcut_service.proto (package slash.api.v2, syntax proto3)
|
|
||||||
/* eslint-disable */
|
|
||||||
// @ts-nocheck
|
|
||||||
|
|
||||||
import { proto3 } from "@bufbuild/protobuf";
|
|
||||||
import { RowStatus } from "./common_pb.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum slash.api.v2.Visibility
|
|
||||||
*/
|
|
||||||
export const Visibility = proto3.makeEnum(
|
|
||||||
"slash.api.v2.Visibility",
|
|
||||||
[
|
|
||||||
{no: 0, name: "VISIBILITY_UNSPECIFIED"},
|
|
||||||
{no: 1, name: "PRIVATE"},
|
|
||||||
{no: 2, name: "WORKSPACE"},
|
|
||||||
{no: 3, name: "PUBLIC"},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.Shortcut
|
|
||||||
*/
|
|
||||||
export const Shortcut = proto3.makeMessageType(
|
|
||||||
"slash.api.v2.Shortcut",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
|
|
||||||
{ no: 2, name: "creator_id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
|
|
||||||
{ no: 3, name: "created_ts", kind: "scalar", T: 3 /* ScalarType.INT64 */ },
|
|
||||||
{ no: 4, name: "updated_ts", kind: "scalar", T: 3 /* ScalarType.INT64 */ },
|
|
||||||
{ no: 5, name: "row_status", kind: "enum", T: proto3.getEnumType(RowStatus) },
|
|
||||||
{ no: 6, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
{ no: 7, name: "link", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
{ no: 8, name: "title", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
{ no: 9, name: "tags", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true },
|
|
||||||
{ no: 10, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
{ no: 11, name: "visibility", kind: "enum", T: proto3.getEnumType(Visibility) },
|
|
||||||
{ no: 12, name: "og_metadata", kind: "message", T: OpenGraphMetadata },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.OpenGraphMetadata
|
|
||||||
*/
|
|
||||||
export const OpenGraphMetadata = proto3.makeMessageType(
|
|
||||||
"slash.api.v2.OpenGraphMetadata",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "title", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
{ no: 2, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
{ no: 3, name: "image", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.ListShortcutsRequest
|
|
||||||
*/
|
|
||||||
export const ListShortcutsRequest = proto3.makeMessageType(
|
|
||||||
"slash.api.v2.ListShortcutsRequest",
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.ListShortcutsResponse
|
|
||||||
*/
|
|
||||||
export const ListShortcutsResponse = proto3.makeMessageType(
|
|
||||||
"slash.api.v2.ListShortcutsResponse",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "shortcuts", kind: "message", T: Shortcut, repeated: true },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.GetShortcutRequest
|
|
||||||
*/
|
|
||||||
export const GetShortcutRequest = proto3.makeMessageType(
|
|
||||||
"slash.api.v2.GetShortcutRequest",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.GetShortcutResponse
|
|
||||||
*/
|
|
||||||
export const GetShortcutResponse = proto3.makeMessageType(
|
|
||||||
"slash.api.v2.GetShortcutResponse",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "shortcut", kind: "message", T: Shortcut },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
@ -1,327 +0,0 @@
|
|||||||
// @generated by protoc-gen-es v1.3.0
|
|
||||||
// @generated from file api/v2/user_service.proto (package slash.api.v2, syntax proto3)
|
|
||||||
/* eslint-disable */
|
|
||||||
// @ts-nocheck
|
|
||||||
|
|
||||||
import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage, Timestamp } from "@bufbuild/protobuf";
|
|
||||||
import { Message, proto3 } from "@bufbuild/protobuf";
|
|
||||||
import type { RowStatus } from "./common_pb.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum slash.api.v2.Role
|
|
||||||
*/
|
|
||||||
export declare enum Role {
|
|
||||||
/**
|
|
||||||
* @generated from enum value: ROLE_UNSPECIFIED = 0;
|
|
||||||
*/
|
|
||||||
ROLE_UNSPECIFIED = 0,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum value: ADMIN = 1;
|
|
||||||
*/
|
|
||||||
ADMIN = 1,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum value: USER = 2;
|
|
||||||
*/
|
|
||||||
USER = 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.User
|
|
||||||
*/
|
|
||||||
export declare class User extends Message<User> {
|
|
||||||
/**
|
|
||||||
* @generated from field: int32 id = 1;
|
|
||||||
*/
|
|
||||||
id: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: slash.api.v2.RowStatus row_status = 2;
|
|
||||||
*/
|
|
||||||
rowStatus: RowStatus;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: int64 created_ts = 3;
|
|
||||||
*/
|
|
||||||
createdTs: bigint;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: int64 updated_ts = 4;
|
|
||||||
*/
|
|
||||||
updatedTs: bigint;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: slash.api.v2.Role role = 6;
|
|
||||||
*/
|
|
||||||
role: Role;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: string email = 7;
|
|
||||||
*/
|
|
||||||
email: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: string nickname = 8;
|
|
||||||
*/
|
|
||||||
nickname: string;
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<User>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.api.v2.User";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): User;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): User;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): User;
|
|
||||||
|
|
||||||
static equals(a: User | PlainMessage<User> | undefined, b: User | PlainMessage<User> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.GetUserRequest
|
|
||||||
*/
|
|
||||||
export declare class GetUserRequest extends Message<GetUserRequest> {
|
|
||||||
/**
|
|
||||||
* @generated from field: int32 id = 1;
|
|
||||||
*/
|
|
||||||
id: number;
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<GetUserRequest>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.api.v2.GetUserRequest";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): GetUserRequest;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): GetUserRequest;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): GetUserRequest;
|
|
||||||
|
|
||||||
static equals(a: GetUserRequest | PlainMessage<GetUserRequest> | undefined, b: GetUserRequest | PlainMessage<GetUserRequest> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.GetUserResponse
|
|
||||||
*/
|
|
||||||
export declare class GetUserResponse extends Message<GetUserResponse> {
|
|
||||||
/**
|
|
||||||
* @generated from field: slash.api.v2.User user = 1;
|
|
||||||
*/
|
|
||||||
user?: User;
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<GetUserResponse>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.api.v2.GetUserResponse";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): GetUserResponse;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): GetUserResponse;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): GetUserResponse;
|
|
||||||
|
|
||||||
static equals(a: GetUserResponse | PlainMessage<GetUserResponse> | undefined, b: GetUserResponse | PlainMessage<GetUserResponse> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.ListUserAccessTokensRequest
|
|
||||||
*/
|
|
||||||
export declare class ListUserAccessTokensRequest extends Message<ListUserAccessTokensRequest> {
|
|
||||||
/**
|
|
||||||
* id is the user id.
|
|
||||||
*
|
|
||||||
* @generated from field: int32 id = 1;
|
|
||||||
*/
|
|
||||||
id: number;
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<ListUserAccessTokensRequest>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.api.v2.ListUserAccessTokensRequest";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): ListUserAccessTokensRequest;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): ListUserAccessTokensRequest;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): ListUserAccessTokensRequest;
|
|
||||||
|
|
||||||
static equals(a: ListUserAccessTokensRequest | PlainMessage<ListUserAccessTokensRequest> | undefined, b: ListUserAccessTokensRequest | PlainMessage<ListUserAccessTokensRequest> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.ListUserAccessTokensResponse
|
|
||||||
*/
|
|
||||||
export declare class ListUserAccessTokensResponse extends Message<ListUserAccessTokensResponse> {
|
|
||||||
/**
|
|
||||||
* @generated from field: repeated slash.api.v2.UserAccessToken access_tokens = 1;
|
|
||||||
*/
|
|
||||||
accessTokens: UserAccessToken[];
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<ListUserAccessTokensResponse>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.api.v2.ListUserAccessTokensResponse";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): ListUserAccessTokensResponse;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): ListUserAccessTokensResponse;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): ListUserAccessTokensResponse;
|
|
||||||
|
|
||||||
static equals(a: ListUserAccessTokensResponse | PlainMessage<ListUserAccessTokensResponse> | undefined, b: ListUserAccessTokensResponse | PlainMessage<ListUserAccessTokensResponse> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.CreateUserAccessTokenRequest
|
|
||||||
*/
|
|
||||||
export declare class CreateUserAccessTokenRequest extends Message<CreateUserAccessTokenRequest> {
|
|
||||||
/**
|
|
||||||
* id is the user id.
|
|
||||||
*
|
|
||||||
* @generated from field: int32 id = 1;
|
|
||||||
*/
|
|
||||||
id: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: slash.api.v2.UserAccessToken user_access_token = 2;
|
|
||||||
*/
|
|
||||||
userAccessToken?: UserAccessToken;
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<CreateUserAccessTokenRequest>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.api.v2.CreateUserAccessTokenRequest";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): CreateUserAccessTokenRequest;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): CreateUserAccessTokenRequest;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): CreateUserAccessTokenRequest;
|
|
||||||
|
|
||||||
static equals(a: CreateUserAccessTokenRequest | PlainMessage<CreateUserAccessTokenRequest> | undefined, b: CreateUserAccessTokenRequest | PlainMessage<CreateUserAccessTokenRequest> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.CreateUserAccessTokenResponse
|
|
||||||
*/
|
|
||||||
export declare class CreateUserAccessTokenResponse extends Message<CreateUserAccessTokenResponse> {
|
|
||||||
/**
|
|
||||||
* @generated from field: slash.api.v2.UserAccessToken access_token = 1;
|
|
||||||
*/
|
|
||||||
accessToken?: UserAccessToken;
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<CreateUserAccessTokenResponse>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.api.v2.CreateUserAccessTokenResponse";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): CreateUserAccessTokenResponse;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): CreateUserAccessTokenResponse;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): CreateUserAccessTokenResponse;
|
|
||||||
|
|
||||||
static equals(a: CreateUserAccessTokenResponse | PlainMessage<CreateUserAccessTokenResponse> | undefined, b: CreateUserAccessTokenResponse | PlainMessage<CreateUserAccessTokenResponse> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.DeleteUserAccessTokenRequest
|
|
||||||
*/
|
|
||||||
export declare class DeleteUserAccessTokenRequest extends Message<DeleteUserAccessTokenRequest> {
|
|
||||||
/**
|
|
||||||
* id is the user id.
|
|
||||||
*
|
|
||||||
* @generated from field: int32 id = 1;
|
|
||||||
*/
|
|
||||||
id: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* access_token is the access token to delete.
|
|
||||||
*
|
|
||||||
* @generated from field: string access_token = 2;
|
|
||||||
*/
|
|
||||||
accessToken: string;
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<DeleteUserAccessTokenRequest>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.api.v2.DeleteUserAccessTokenRequest";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): DeleteUserAccessTokenRequest;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): DeleteUserAccessTokenRequest;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): DeleteUserAccessTokenRequest;
|
|
||||||
|
|
||||||
static equals(a: DeleteUserAccessTokenRequest | PlainMessage<DeleteUserAccessTokenRequest> | undefined, b: DeleteUserAccessTokenRequest | PlainMessage<DeleteUserAccessTokenRequest> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.DeleteUserAccessTokenResponse
|
|
||||||
*/
|
|
||||||
export declare class DeleteUserAccessTokenResponse extends Message<DeleteUserAccessTokenResponse> {
|
|
||||||
constructor(data?: PartialMessage<DeleteUserAccessTokenResponse>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.api.v2.DeleteUserAccessTokenResponse";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): DeleteUserAccessTokenResponse;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): DeleteUserAccessTokenResponse;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): DeleteUserAccessTokenResponse;
|
|
||||||
|
|
||||||
static equals(a: DeleteUserAccessTokenResponse | PlainMessage<DeleteUserAccessTokenResponse> | undefined, b: DeleteUserAccessTokenResponse | PlainMessage<DeleteUserAccessTokenResponse> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.UserAccessToken
|
|
||||||
*/
|
|
||||||
export declare class UserAccessToken extends Message<UserAccessToken> {
|
|
||||||
/**
|
|
||||||
* @generated from field: string access_token = 1;
|
|
||||||
*/
|
|
||||||
accessToken: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: string description = 2;
|
|
||||||
*/
|
|
||||||
description: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: google.protobuf.Timestamp issued_at = 3;
|
|
||||||
*/
|
|
||||||
issuedAt?: Timestamp;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: google.protobuf.Timestamp expires_at = 4;
|
|
||||||
*/
|
|
||||||
expiresAt?: Timestamp;
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<UserAccessToken>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.api.v2.UserAccessToken";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): UserAccessToken;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): UserAccessToken;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): UserAccessToken;
|
|
||||||
|
|
||||||
static equals(a: UserAccessToken | PlainMessage<UserAccessToken> | undefined, b: UserAccessToken | PlainMessage<UserAccessToken> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
@ -1,129 +0,0 @@
|
|||||||
// @generated by protoc-gen-es v1.3.0
|
|
||||||
// @generated from file api/v2/user_service.proto (package slash.api.v2, syntax proto3)
|
|
||||||
/* eslint-disable */
|
|
||||||
// @ts-nocheck
|
|
||||||
|
|
||||||
import { proto3, Timestamp } from "@bufbuild/protobuf";
|
|
||||||
import { RowStatus } from "./common_pb.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum slash.api.v2.Role
|
|
||||||
*/
|
|
||||||
export const Role = proto3.makeEnum(
|
|
||||||
"slash.api.v2.Role",
|
|
||||||
[
|
|
||||||
{no: 0, name: "ROLE_UNSPECIFIED"},
|
|
||||||
{no: 1, name: "ADMIN"},
|
|
||||||
{no: 2, name: "USER"},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.User
|
|
||||||
*/
|
|
||||||
export const User = proto3.makeMessageType(
|
|
||||||
"slash.api.v2.User",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
|
|
||||||
{ no: 2, name: "row_status", kind: "enum", T: proto3.getEnumType(RowStatus) },
|
|
||||||
{ no: 3, name: "created_ts", kind: "scalar", T: 3 /* ScalarType.INT64 */ },
|
|
||||||
{ no: 4, name: "updated_ts", kind: "scalar", T: 3 /* ScalarType.INT64 */ },
|
|
||||||
{ no: 6, name: "role", kind: "enum", T: proto3.getEnumType(Role) },
|
|
||||||
{ no: 7, name: "email", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
{ no: 8, name: "nickname", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.GetUserRequest
|
|
||||||
*/
|
|
||||||
export const GetUserRequest = proto3.makeMessageType(
|
|
||||||
"slash.api.v2.GetUserRequest",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.GetUserResponse
|
|
||||||
*/
|
|
||||||
export const GetUserResponse = proto3.makeMessageType(
|
|
||||||
"slash.api.v2.GetUserResponse",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "user", kind: "message", T: User },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.ListUserAccessTokensRequest
|
|
||||||
*/
|
|
||||||
export const ListUserAccessTokensRequest = proto3.makeMessageType(
|
|
||||||
"slash.api.v2.ListUserAccessTokensRequest",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.ListUserAccessTokensResponse
|
|
||||||
*/
|
|
||||||
export const ListUserAccessTokensResponse = proto3.makeMessageType(
|
|
||||||
"slash.api.v2.ListUserAccessTokensResponse",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "access_tokens", kind: "message", T: UserAccessToken, repeated: true },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.CreateUserAccessTokenRequest
|
|
||||||
*/
|
|
||||||
export const CreateUserAccessTokenRequest = proto3.makeMessageType(
|
|
||||||
"slash.api.v2.CreateUserAccessTokenRequest",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
|
|
||||||
{ no: 2, name: "user_access_token", kind: "message", T: UserAccessToken },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.CreateUserAccessTokenResponse
|
|
||||||
*/
|
|
||||||
export const CreateUserAccessTokenResponse = proto3.makeMessageType(
|
|
||||||
"slash.api.v2.CreateUserAccessTokenResponse",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "access_token", kind: "message", T: UserAccessToken },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.DeleteUserAccessTokenRequest
|
|
||||||
*/
|
|
||||||
export const DeleteUserAccessTokenRequest = proto3.makeMessageType(
|
|
||||||
"slash.api.v2.DeleteUserAccessTokenRequest",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
|
|
||||||
{ no: 2, name: "access_token", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.DeleteUserAccessTokenResponse
|
|
||||||
*/
|
|
||||||
export const DeleteUserAccessTokenResponse = proto3.makeMessageType(
|
|
||||||
"slash.api.v2.DeleteUserAccessTokenResponse",
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.UserAccessToken
|
|
||||||
*/
|
|
||||||
export const UserAccessToken = proto3.makeMessageType(
|
|
||||||
"slash.api.v2.UserAccessToken",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "access_token", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
{ no: 2, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
{ no: 3, name: "issued_at", kind: "message", T: Timestamp },
|
|
||||||
{ no: 4, name: "expires_at", kind: "message", T: Timestamp },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
25
extension/src/types/proto/store/common_pb.d.ts
vendored
25
extension/src/types/proto/store/common_pb.d.ts
vendored
@ -1,25 +0,0 @@
|
|||||||
// @generated by protoc-gen-es v1.3.0
|
|
||||||
// @generated from file store/common.proto (package slash.store, syntax proto3)
|
|
||||||
/* eslint-disable */
|
|
||||||
// @ts-nocheck
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum slash.store.RowStatus
|
|
||||||
*/
|
|
||||||
export declare enum RowStatus {
|
|
||||||
/**
|
|
||||||
* @generated from enum value: ROW_STATUS_UNSPECIFIED = 0;
|
|
||||||
*/
|
|
||||||
ROW_STATUS_UNSPECIFIED = 0,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum value: NORMAL = 1;
|
|
||||||
*/
|
|
||||||
NORMAL = 1,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum value: ARCHIVED = 2;
|
|
||||||
*/
|
|
||||||
ARCHIVED = 2,
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
|||||||
// @generated by protoc-gen-es v1.3.0
|
|
||||||
// @generated from file store/common.proto (package slash.store, syntax proto3)
|
|
||||||
/* eslint-disable */
|
|
||||||
// @ts-nocheck
|
|
||||||
|
|
||||||
import { proto3 } from "@bufbuild/protobuf";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum slash.store.RowStatus
|
|
||||||
*/
|
|
||||||
export const RowStatus = proto3.makeEnum(
|
|
||||||
"slash.store.RowStatus",
|
|
||||||
[
|
|
||||||
{no: 0, name: "ROW_STATUS_UNSPECIFIED"},
|
|
||||||
{no: 1, name: "NORMAL"},
|
|
||||||
{no: 2, name: "ARCHIVED"},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
147
extension/src/types/proto/store/shortcut_pb.d.ts
vendored
147
extension/src/types/proto/store/shortcut_pb.d.ts
vendored
@ -1,147 +0,0 @@
|
|||||||
// @generated by protoc-gen-es v1.3.0
|
|
||||||
// @generated from file store/shortcut.proto (package slash.store, syntax proto3)
|
|
||||||
/* eslint-disable */
|
|
||||||
// @ts-nocheck
|
|
||||||
|
|
||||||
import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf";
|
|
||||||
import { Message, proto3 } from "@bufbuild/protobuf";
|
|
||||||
import type { RowStatus } from "./common_pb.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum slash.store.Visibility
|
|
||||||
*/
|
|
||||||
export declare enum Visibility {
|
|
||||||
/**
|
|
||||||
* @generated from enum value: VISIBILITY_UNSPECIFIED = 0;
|
|
||||||
*/
|
|
||||||
VISIBILITY_UNSPECIFIED = 0,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum value: PRIVATE = 1;
|
|
||||||
*/
|
|
||||||
PRIVATE = 1,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum value: WORKSPACE = 2;
|
|
||||||
*/
|
|
||||||
WORKSPACE = 2,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum value: PUBLIC = 3;
|
|
||||||
*/
|
|
||||||
PUBLIC = 3,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.store.Shortcut
|
|
||||||
*/
|
|
||||||
export declare class Shortcut extends Message<Shortcut> {
|
|
||||||
/**
|
|
||||||
* @generated from field: int32 id = 1;
|
|
||||||
*/
|
|
||||||
id: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: int32 creator_id = 2;
|
|
||||||
*/
|
|
||||||
creatorId: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: int64 created_ts = 3;
|
|
||||||
*/
|
|
||||||
createdTs: bigint;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: int64 updated_ts = 4;
|
|
||||||
*/
|
|
||||||
updatedTs: bigint;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: slash.store.RowStatus row_status = 5;
|
|
||||||
*/
|
|
||||||
rowStatus: RowStatus;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: string name = 6;
|
|
||||||
*/
|
|
||||||
name: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: string link = 7;
|
|
||||||
*/
|
|
||||||
link: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: string title = 8;
|
|
||||||
*/
|
|
||||||
title: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: repeated string tags = 9;
|
|
||||||
*/
|
|
||||||
tags: string[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: string description = 10;
|
|
||||||
*/
|
|
||||||
description: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: slash.store.Visibility visibility = 11;
|
|
||||||
*/
|
|
||||||
visibility: Visibility;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: slash.store.OpenGraphMetadata og_metadata = 12;
|
|
||||||
*/
|
|
||||||
ogMetadata?: OpenGraphMetadata;
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<Shortcut>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.store.Shortcut";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): Shortcut;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): Shortcut;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): Shortcut;
|
|
||||||
|
|
||||||
static equals(a: Shortcut | PlainMessage<Shortcut> | undefined, b: Shortcut | PlainMessage<Shortcut> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.store.OpenGraphMetadata
|
|
||||||
*/
|
|
||||||
export declare class OpenGraphMetadata extends Message<OpenGraphMetadata> {
|
|
||||||
/**
|
|
||||||
* @generated from field: string title = 1;
|
|
||||||
*/
|
|
||||||
title: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: string description = 2;
|
|
||||||
*/
|
|
||||||
description: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: string image = 3;
|
|
||||||
*/
|
|
||||||
image: string;
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<OpenGraphMetadata>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.store.OpenGraphMetadata";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): OpenGraphMetadata;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): OpenGraphMetadata;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): OpenGraphMetadata;
|
|
||||||
|
|
||||||
static equals(a: OpenGraphMetadata | PlainMessage<OpenGraphMetadata> | undefined, b: OpenGraphMetadata | PlainMessage<OpenGraphMetadata> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
|||||||
// @generated by protoc-gen-es v1.3.0
|
|
||||||
// @generated from file store/shortcut.proto (package slash.store, syntax proto3)
|
|
||||||
/* eslint-disable */
|
|
||||||
// @ts-nocheck
|
|
||||||
|
|
||||||
import { proto3 } from "@bufbuild/protobuf";
|
|
||||||
import { RowStatus } from "./common_pb.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum slash.store.Visibility
|
|
||||||
*/
|
|
||||||
export const Visibility = proto3.makeEnum(
|
|
||||||
"slash.store.Visibility",
|
|
||||||
[
|
|
||||||
{no: 0, name: "VISIBILITY_UNSPECIFIED"},
|
|
||||||
{no: 1, name: "PRIVATE"},
|
|
||||||
{no: 2, name: "WORKSPACE"},
|
|
||||||
{no: 3, name: "PUBLIC"},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.store.Shortcut
|
|
||||||
*/
|
|
||||||
export const Shortcut = proto3.makeMessageType(
|
|
||||||
"slash.store.Shortcut",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
|
|
||||||
{ no: 2, name: "creator_id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
|
|
||||||
{ no: 3, name: "created_ts", kind: "scalar", T: 3 /* ScalarType.INT64 */ },
|
|
||||||
{ no: 4, name: "updated_ts", kind: "scalar", T: 3 /* ScalarType.INT64 */ },
|
|
||||||
{ no: 5, name: "row_status", kind: "enum", T: proto3.getEnumType(RowStatus) },
|
|
||||||
{ no: 6, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
{ no: 7, name: "link", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
{ no: 8, name: "title", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
{ no: 9, name: "tags", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true },
|
|
||||||
{ no: 10, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
{ no: 11, name: "visibility", kind: "enum", T: proto3.getEnumType(Visibility) },
|
|
||||||
{ no: 12, name: "og_metadata", kind: "message", T: OpenGraphMetadata },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.store.OpenGraphMetadata
|
|
||||||
*/
|
|
||||||
export const OpenGraphMetadata = proto3.makeMessageType(
|
|
||||||
"slash.store.OpenGraphMetadata",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "title", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
{ no: 2, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
{ no: 3, name: "image", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
116
extension/src/types/proto/store/user_setting_pb.d.ts
vendored
116
extension/src/types/proto/store/user_setting_pb.d.ts
vendored
@ -1,116 +0,0 @@
|
|||||||
// @generated by protoc-gen-es v1.3.0
|
|
||||||
// @generated from file store/user_setting.proto (package slash.store, syntax proto3)
|
|
||||||
/* eslint-disable */
|
|
||||||
// @ts-nocheck
|
|
||||||
|
|
||||||
import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf";
|
|
||||||
import { Message, proto3 } from "@bufbuild/protobuf";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum slash.store.UserSettingKey
|
|
||||||
*/
|
|
||||||
export declare enum UserSettingKey {
|
|
||||||
/**
|
|
||||||
* @generated from enum value: USER_SETTING_KEY_UNSPECIFIED = 0;
|
|
||||||
*/
|
|
||||||
USER_SETTING_KEY_UNSPECIFIED = 0,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum value: USER_SETTING_ACCESS_TOKENS = 1;
|
|
||||||
*/
|
|
||||||
USER_SETTING_ACCESS_TOKENS = 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.store.UserSetting
|
|
||||||
*/
|
|
||||||
export declare class UserSetting extends Message<UserSetting> {
|
|
||||||
/**
|
|
||||||
* @generated from field: int32 user_id = 1;
|
|
||||||
*/
|
|
||||||
userId: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: slash.store.UserSettingKey key = 2;
|
|
||||||
*/
|
|
||||||
key: UserSettingKey;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from oneof slash.store.UserSetting.value
|
|
||||||
*/
|
|
||||||
value: {
|
|
||||||
/**
|
|
||||||
* @generated from field: slash.store.AccessTokensUserSetting access_tokens_user_setting = 3;
|
|
||||||
*/
|
|
||||||
value: AccessTokensUserSetting;
|
|
||||||
case: "accessTokensUserSetting";
|
|
||||||
} | { case: undefined; value?: undefined };
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<UserSetting>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.store.UserSetting";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): UserSetting;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): UserSetting;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): UserSetting;
|
|
||||||
|
|
||||||
static equals(a: UserSetting | PlainMessage<UserSetting> | undefined, b: UserSetting | PlainMessage<UserSetting> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.store.AccessTokensUserSetting
|
|
||||||
*/
|
|
||||||
export declare class AccessTokensUserSetting extends Message<AccessTokensUserSetting> {
|
|
||||||
/**
|
|
||||||
* @generated from field: repeated slash.store.AccessTokensUserSetting.AccessToken access_tokens = 1;
|
|
||||||
*/
|
|
||||||
accessTokens: AccessTokensUserSetting_AccessToken[];
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<AccessTokensUserSetting>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.store.AccessTokensUserSetting";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): AccessTokensUserSetting;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): AccessTokensUserSetting;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): AccessTokensUserSetting;
|
|
||||||
|
|
||||||
static equals(a: AccessTokensUserSetting | PlainMessage<AccessTokensUserSetting> | undefined, b: AccessTokensUserSetting | PlainMessage<AccessTokensUserSetting> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.store.AccessTokensUserSetting.AccessToken
|
|
||||||
*/
|
|
||||||
export declare class AccessTokensUserSetting_AccessToken extends Message<AccessTokensUserSetting_AccessToken> {
|
|
||||||
/**
|
|
||||||
* @generated from field: string access_token = 1;
|
|
||||||
*/
|
|
||||||
accessToken: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: string description = 2;
|
|
||||||
*/
|
|
||||||
description: string;
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<AccessTokensUserSetting_AccessToken>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.store.AccessTokensUserSetting.AccessToken";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): AccessTokensUserSetting_AccessToken;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): AccessTokensUserSetting_AccessToken;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): AccessTokensUserSetting_AccessToken;
|
|
||||||
|
|
||||||
static equals(a: AccessTokensUserSetting_AccessToken | PlainMessage<AccessTokensUserSetting_AccessToken> | undefined, b: AccessTokensUserSetting_AccessToken | PlainMessage<AccessTokensUserSetting_AccessToken> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
|||||||
// @generated by protoc-gen-es v1.3.0
|
|
||||||
// @generated from file store/user_setting.proto (package slash.store, syntax proto3)
|
|
||||||
/* eslint-disable */
|
|
||||||
// @ts-nocheck
|
|
||||||
|
|
||||||
import { proto3 } from "@bufbuild/protobuf";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum slash.store.UserSettingKey
|
|
||||||
*/
|
|
||||||
export const UserSettingKey = proto3.makeEnum(
|
|
||||||
"slash.store.UserSettingKey",
|
|
||||||
[
|
|
||||||
{no: 0, name: "USER_SETTING_KEY_UNSPECIFIED"},
|
|
||||||
{no: 1, name: "USER_SETTING_ACCESS_TOKENS"},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.store.UserSetting
|
|
||||||
*/
|
|
||||||
export const UserSetting = proto3.makeMessageType(
|
|
||||||
"slash.store.UserSetting",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "user_id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
|
|
||||||
{ no: 2, name: "key", kind: "enum", T: proto3.getEnumType(UserSettingKey) },
|
|
||||||
{ no: 3, name: "access_tokens_user_setting", kind: "message", T: AccessTokensUserSetting, oneof: "value" },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.store.AccessTokensUserSetting
|
|
||||||
*/
|
|
||||||
export const AccessTokensUserSetting = proto3.makeMessageType(
|
|
||||||
"slash.store.AccessTokensUserSetting",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "access_tokens", kind: "message", T: AccessTokensUserSetting_AccessToken, repeated: true },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.store.AccessTokensUserSetting.AccessToken
|
|
||||||
*/
|
|
||||||
export const AccessTokensUserSetting_AccessToken = proto3.makeMessageType(
|
|
||||||
"slash.store.AccessTokensUserSetting.AccessToken",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "access_token", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
{ no: 2, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
],
|
|
||||||
{localName: "AccessTokensUserSetting_AccessToken"},
|
|
||||||
);
|
|
||||||
|
|
@ -36,3 +36,5 @@ keys.json
|
|||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
.tsbuildinfo
|
.tsbuildinfo
|
||||||
|
|
||||||
|
src/types/proto
|
@ -4,5 +4,5 @@ module.exports = {
|
|||||||
semi: true,
|
semi: true,
|
||||||
singleQuote: false,
|
singleQuote: false,
|
||||||
plugins: [require.resolve("@trivago/prettier-plugin-sort-imports")],
|
plugins: [require.resolve("@trivago/prettier-plugin-sort-imports")],
|
||||||
importOrder: ["<BUILTIN_MODULES>", "<THIRD_PARTY_MODULES>", "^@/((?!less).+)", "^[./]", "^(.+).css"],
|
importOrder: ["<BUILTIN_MODULES>", "<THIRD_PARTY_MODULES>", "^@/((?!css).+)", "^[./]", "^[../]", "^(.+).css"],
|
||||||
};
|
};
|
BIN
frontend/extension/assets/icon.png
Normal file
BIN
frontend/extension/assets/icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 257 KiB |
@ -1,22 +1,22 @@
|
|||||||
{
|
{
|
||||||
"name": "slash-extexnsion",
|
"name": "slash-extension",
|
||||||
"displayName": "Slash",
|
"displayName": "Slash",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"description": "An open source, self-hosted bookmarks and link sharing platform. Save and share your links very easily.",
|
"description": "An open source, self-hosted bookmarks and link sharing platform. Save and share your links very easily.",
|
||||||
"author": "steven",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "plasmo dev",
|
"dev": "plasmo dev",
|
||||||
"build": "plasmo build",
|
"build": "plasmo build",
|
||||||
"package": "plasmo package",
|
"package": "plasmo package",
|
||||||
"lint": "eslint --ext .js,.ts,.tsx, src",
|
"lint": "eslint --ext .js,.ts,.tsx, src",
|
||||||
"lint-fix": "eslint --ext .js,.ts,.tsx, src --fix"
|
"lint-fix": "eslint --ext .js,.ts,.tsx, src --fix",
|
||||||
|
"type-gen": "cd ../../proto && buf generate"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.11.1",
|
"@emotion/react": "^11.11.1",
|
||||||
"@emotion/styled": "^11.11.0",
|
"@emotion/styled": "^11.11.0",
|
||||||
"@mui/joy": "5.0.0-beta.0",
|
"@mui/joy": "5.0.0-beta.0",
|
||||||
"@plasmohq/storage": "^1.7.2",
|
"@plasmohq/storage": "^1.8.1",
|
||||||
"axios": "^1.4.0",
|
"axios": "^1.5.1",
|
||||||
"classnames": "^2.3.2",
|
"classnames": "^2.3.2",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"lucide-react": "^0.264.0",
|
"lucide-react": "^0.264.0",
|
||||||
@ -24,36 +24,36 @@
|
|||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-hot-toast": "^2.4.1",
|
"react-hot-toast": "^2.4.1",
|
||||||
"zustand": "^4.4.1"
|
"zustand": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@bufbuild/buf": "^1.27.0",
|
||||||
"@trivago/prettier-plugin-sort-imports": "4.1.0",
|
"@trivago/prettier-plugin-sort-imports": "4.1.0",
|
||||||
"@types/chrome": "0.0.241",
|
"@types/chrome": "0.0.241",
|
||||||
"@types/lodash-es": "^4.17.8",
|
"@types/lodash-es": "^4.17.9",
|
||||||
"@types/node": "20.4.2",
|
"@types/node": "20.4.2",
|
||||||
"@types/react": "18.2.15",
|
"@types/react": "18.2.15",
|
||||||
"@types/react-dom": "18.2.7",
|
"@types/react-dom": "18.2.7",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.2.0",
|
"@typescript-eslint/eslint-plugin": "^6.8.0",
|
||||||
"@typescript-eslint/parser": "^6.2.0",
|
"@typescript-eslint/parser": "^6.8.0",
|
||||||
"autoprefixer": "^10.4.14",
|
"autoprefixer": "^10.4.16",
|
||||||
"eslint": "^8.46.0",
|
"eslint": "^8.51.0",
|
||||||
"eslint-config-prettier": "^8.3.0",
|
"eslint-config-prettier": "^8.10.0",
|
||||||
"eslint-plugin-prettier": "^4.0.0",
|
"eslint-plugin-prettier": "^4.2.1",
|
||||||
"eslint-plugin-react": "^7.27.1",
|
"eslint-plugin-react": "^7.33.2",
|
||||||
"postcss": "^8.4.27",
|
"long": "^5.2.3",
|
||||||
|
"postcss": "^8.4.31",
|
||||||
"prettier": "2.6.2",
|
"prettier": "2.6.2",
|
||||||
|
"protobufjs": "^7.2.5",
|
||||||
"tailwindcss": "^3.3.3",
|
"tailwindcss": "^3.3.3",
|
||||||
"typescript": "5.1.6"
|
"typescript": "5.1.6"
|
||||||
},
|
},
|
||||||
"manifest": {
|
"manifest": {
|
||||||
"host_permissions": [
|
"omnibox": {
|
||||||
"http://*/*",
|
"keyword": "s"
|
||||||
"https://*/*"
|
},
|
||||||
],
|
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"tabs",
|
"tabs",
|
||||||
"activeTab",
|
|
||||||
"scripting",
|
|
||||||
"storage"
|
"storage"
|
||||||
]
|
]
|
||||||
}
|
}
|
1671
extension/pnpm-lock.yaml → frontend/extension/pnpm-lock.yaml
generated
1671
extension/pnpm-lock.yaml → frontend/extension/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,4 @@
|
|||||||
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service_pb";
|
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||||
import { Storage } from "@plasmohq/storage";
|
import { Storage } from "@plasmohq/storage";
|
||||||
|
|
||||||
const storage = new Storage();
|
const storage = new Storage();
|
||||||
@ -20,6 +20,15 @@ chrome.tabs.onUpdated.addListener(async (tabId, _, tab) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 getShortcutNameFromUrl = (urlString: string) => {
|
||||||
const matchResult = urlRegex.exec(urlString);
|
const matchResult = urlRegex.exec(urlString);
|
||||||
if (matchResult === null) {
|
if (matchResult === null) {
|
173
frontend/extension/src/components/CreateShortcutsButton.tsx
Normal file
173
frontend/extension/src/components/CreateShortcutsButton.tsx
Normal 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;
|
12
frontend/extension/src/components/Logo.tsx
Normal file
12
frontend/extension/src/components/Logo.tsx
Normal 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;
|
@ -1,17 +1,15 @@
|
|||||||
import { Button } from "@mui/joy";
|
import { IconButton } from "@mui/joy";
|
||||||
import { useStorage } from "@plasmohq/storage/hook";
|
import { useStorage } from "@plasmohq/storage/hook";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { ListShortcutsResponse } from "@/types/proto/api/v2/shortcut_service_pb";
|
import { ListShortcutsResponse } from "@/types/proto/api/v2/shortcut_service";
|
||||||
import "../style.css";
|
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
const PullShortcutsButton = () => {
|
const PullShortcutsButton = () => {
|
||||||
const [domain] = useStorage("domain");
|
const [domain] = useStorage("domain");
|
||||||
const [accessToken] = useStorage("access_token");
|
const [accessToken] = useStorage("access_token");
|
||||||
const [, setShortcuts] = useStorage("shortcuts");
|
const [, setShortcuts] = useStorage("shortcuts");
|
||||||
const [isPulling, setIsPulling] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (domain && accessToken) {
|
if (domain && accessToken) {
|
||||||
@ -21,7 +19,6 @@ const PullShortcutsButton = () => {
|
|||||||
|
|
||||||
const handlePullShortcuts = async (silence = false) => {
|
const handlePullShortcuts = async (silence = false) => {
|
||||||
try {
|
try {
|
||||||
setIsPulling(true);
|
|
||||||
const {
|
const {
|
||||||
data: { shortcuts },
|
data: { shortcuts },
|
||||||
} = await axios.get<ListShortcutsResponse>(`${domain}/api/v2/shortcuts`, {
|
} = await axios.get<ListShortcutsResponse>(`${domain}/api/v2/shortcuts`, {
|
||||||
@ -36,13 +33,12 @@ const PullShortcutsButton = () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Failed to pull shortcuts, error: " + error.message);
|
toast.error("Failed to pull shortcuts, error: " + error.message);
|
||||||
}
|
}
|
||||||
setIsPulling(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button loading={isPulling} color="neutral" variant="plain" size="sm" onClick={() => handlePullShortcuts()}>
|
<IconButton color="neutral" variant="plain" size="sm" onClick={() => handlePullShortcuts()}>
|
||||||
<Icon.RefreshCcw className="w-4 h-auto" />
|
<Icon.RefreshCcw className="w-4 h-auto" />
|
||||||
</Button>
|
</IconButton>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -1,8 +1,7 @@
|
|||||||
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service_pb";
|
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||||
import { useStorage } from "@plasmohq/storage/hook";
|
import { useStorage } from "@plasmohq/storage/hook";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useEffect, useState } from "react";
|
import { getFaviconWithGoogleS2 } from "@/helpers/utils";
|
||||||
import useFaviconStore from "../stores/favicon";
|
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -11,17 +10,8 @@ interface Props {
|
|||||||
|
|
||||||
const ShortcutView = (props: Props) => {
|
const ShortcutView = (props: Props) => {
|
||||||
const { shortcut } = props;
|
const { shortcut } = props;
|
||||||
const faviconStore = useFaviconStore();
|
|
||||||
const [domain] = useStorage<string>("domain", "");
|
const [domain] = useStorage<string>("domain", "");
|
||||||
const [favicon, setFavicon] = useState<string | undefined>(undefined);
|
const favicon = getFaviconWithGoogleS2(shortcut.link);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
faviconStore.getOrFetchUrlFavicon(shortcut.link).then((url) => {
|
|
||||||
if (url) {
|
|
||||||
setFavicon(url);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [shortcut.link]);
|
|
||||||
|
|
||||||
const handleShortcutLinkClick = () => {
|
const handleShortcutLinkClick = () => {
|
||||||
const shortcutLink = `${domain}/s/${shortcut.name}`;
|
const shortcutLink = `${domain}/s/${shortcut.name}`;
|
||||||
@ -32,7 +22,7 @@ const ShortcutView = (props: Props) => {
|
|||||||
<>
|
<>
|
||||||
<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-start items-center">
|
<div className="w-full flex flex-row justify-start items-center">
|
||||||
@ -52,13 +42,13 @@ const ShortcutView = (props: Props) => {
|
|||||||
onClick={handleShortcutLinkClick}
|
onClick={handleShortcutLinkClick}
|
||||||
>
|
>
|
||||||
<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>
|
@ -1,4 +1,4 @@
|
|||||||
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service_pb";
|
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||||
import { useStorage } from "@plasmohq/storage/hook";
|
import { useStorage } from "@plasmohq/storage/hook";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import ShortcutView from "./ShortcutView";
|
import ShortcutView from "./ShortcutView";
|
14
frontend/extension/src/helpers/utils.ts
Normal file
14
frontend/extension/src/helpers/utils.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
43
frontend/extension/src/hooks/useColorTheme.ts
Normal file
43
frontend/extension/src/hooks/useColorTheme.ts
Normal 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;
|
179
frontend/extension/src/options.tsx
Normal file
179
frontend/extension/src/options.tsx
Normal 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;
|
110
frontend/extension/src/popup.tsx
Normal file
110
frontend/extension/src/popup.tsx
Normal 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;
|
@ -5,7 +5,7 @@
|
|||||||
body,
|
body,
|
||||||
html,
|
html,
|
||||||
#root {
|
#root {
|
||||||
@apply text-base;
|
@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";
|
@ -6,13 +6,14 @@
|
|||||||
"include": [
|
"include": [
|
||||||
".plasmo/index.d.ts",
|
".plasmo/index.d.ts",
|
||||||
"./**/*.ts",
|
"./**/*.ts",
|
||||||
"./**/*.tsx"
|
"./**/*.tsx",
|
||||||
|
"../types"
|
||||||
],
|
],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": [
|
"@/*": [
|
||||||
"./src/*"
|
"./src/*"
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
"baseUrl": "."
|
"baseUrl": "."
|
||||||
}
|
}
|
3
frontend/locales/README.md
Normal file
3
frontend/locales/README.md
Normal 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
82
frontend/locales/en.json
Normal 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
82
frontend/locales/zh.json
Normal 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": "允许其他用户注册新账号"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
1
web/.gitignore → frontend/web/.gitignore
vendored
1
web/.gitignore → frontend/web/.gitignore
vendored
@ -3,3 +3,4 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
src/types/proto
|
56
frontend/web/package.json
Normal file
56
frontend/web/package.json
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"type-gen": "cd ../../proto && buf generate"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.11.1",
|
||||||
|
"@emotion/styled": "^11.11.0",
|
||||||
|
"@mui/joy": "5.0.0-beta.7",
|
||||||
|
"@reduxjs/toolkit": "^1.9.7",
|
||||||
|
"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.3.0",
|
||||||
|
"react-redux": "^8.1.3",
|
||||||
|
"react-router-dom": "^6.17.0",
|
||||||
|
"react-use": "^17.4.0",
|
||||||
|
"tailwindcss": "^3.3.3",
|
||||||
|
"zustand": "^4.4.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@bufbuild/buf": "^1.27.0",
|
||||||
|
"@trivago/prettier-plugin-sort-imports": "^4.2.0",
|
||||||
|
"@types/lodash-es": "^4.17.9",
|
||||||
|
"@types/react": "^18.2.28",
|
||||||
|
"@types/react-dom": "^18.2.13",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.8.0",
|
||||||
|
"@typescript-eslint/parser": "^6.8.0",
|
||||||
|
"@vitejs/plugin-react-swc": "^3.4.0",
|
||||||
|
"autoprefixer": "^10.4.16",
|
||||||
|
"eslint": "^8.51.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.11"
|
||||||
|
}
|
||||||
|
}
|
1872
web/pnpm-lock.yaml → frontend/web/pnpm-lock.yaml
generated
1872
web/pnpm-lock.yaml → frontend/web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
BIN
frontend/web/public/logo.png
Normal file
BIN
frontend/web/public/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 257 KiB |
75
frontend/web/src/App.tsx
Normal file
75
frontend/web/src/App.tsx
Normal 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;
|
@ -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>
|
@ -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>
|
9
frontend/web/src/components/BetaBadge.tsx
Normal file
9
frontend/web/src/components/BetaBadge.tsx
Normal 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;
|
@ -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>
|
@ -1,7 +1,8 @@
|
|||||||
import { Button, Input, Modal, ModalDialog, Radio, RadioGroup } from "@mui/joy";
|
import { Button, Input, Modal, ModalDialog, Radio, RadioGroup } from "@mui/joy";
|
||||||
import axios from "axios";
|
|
||||||
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 { userServiceClient } from "@/grpcweb";
|
||||||
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";
|
||||||
@ -33,6 +34,7 @@ interface State {
|
|||||||
|
|
||||||
const CreateAccessTokenDialog: React.FC<Props> = (props: Props) => {
|
const CreateAccessTokenDialog: React.FC<Props> = (props: Props) => {
|
||||||
const { onClose, onConfirm } = props;
|
const { onClose, onConfirm } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
const currentUser = useUserStore().getCurrentUser();
|
const currentUser = useUserStore().getCurrentUser();
|
||||||
const [state, setState] = useState({
|
const [state, setState] = useState({
|
||||||
description: "",
|
description: "",
|
||||||
@ -66,9 +68,10 @@ const CreateAccessTokenDialog: React.FC<Props> = (props: Props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.post(`/api/v2/users/${currentUser.id}/access_tokens`, {
|
await userServiceClient.createUserAccessToken({
|
||||||
|
id: currentUser.id,
|
||||||
description: state.description,
|
description: state.description,
|
||||||
expiresAt: new Date(Date.now() + state.expiration * 1000),
|
expiresAt: state.expiration ? new Date(Date.now() + state.expiration * 1000) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (onConfirm) {
|
if (onConfirm) {
|
||||||
@ -112,17 +115,17 @@ const CreateAccessTokenDialog: React.FC<Props> = (props: Props) => {
|
|||||||
<div className="w-full flex flex-row justify-start items-center text-base">
|
<div className="w-full flex flex-row justify-start items-center text-base">
|
||||||
<RadioGroup orientation="horizontal" value={state.expiration} onChange={handleRoleInputChange}>
|
<RadioGroup orientation="horizontal" value={state.expiration} onChange={handleRoleInputChange}>
|
||||||
{expirationOptions.map((option) => (
|
{expirationOptions.map((option) => (
|
||||||
<Radio key={option.value} value={option.value} label={option.label} />
|
<Radio key={option.value} value={option.value} checked={state.expiration === option.value} label={option.label} />
|
||||||
))}
|
))}
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</div>
|
</div>
|
||||||
</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}>
|
||||||
Create
|
{t("common.create")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
@ -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>
|
@ -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>
|
@ -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"
|
@ -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>
|
@ -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>
|
91
frontend/web/src/components/Header.tsx
Normal file
91
frontend/web/src/components/Header.tsx
Normal 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 rounded-full shadow" 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;
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user