mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Compare commits
187
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24d90bb750 | ||
|
|
c2726e6b18 | ||
|
|
a746ce9169 | ||
|
|
c00b967179 | ||
|
|
f6e749ecf9 | ||
|
|
8f5e0686c8 | ||
|
|
b9f0d9279b | ||
|
|
cd69f0c82f | ||
|
|
884746f3a5 | ||
|
|
9c4d862297 | ||
|
|
cc87cf68b5 | ||
|
|
7c5d57ddc1 | ||
|
|
5653ff18c8 | ||
|
|
83ea444675 | ||
|
|
966edfa85a | ||
|
|
30b9fc7765 | ||
|
|
95a60e9a96 | ||
|
|
9327331adc | ||
|
|
e8baa441cd | ||
|
|
61d142afbd | ||
|
|
46022e8711 | ||
|
|
e59f3c46fd | ||
|
|
7fcb3f2c31 | ||
|
|
a2243585a0 | ||
|
|
6231002043 | ||
|
|
33eb476009 | ||
|
|
ebb0a4c7e5 | ||
|
|
d010ed6bc5 | ||
|
|
1941e29175 | ||
|
|
6e8932ff52 | ||
|
|
f9bfae9b9d | ||
|
|
2fca928cf0 | ||
|
|
8f3a362a3a | ||
|
|
62a6189788 | ||
|
|
2a6d631719 | ||
|
|
4942591d7f | ||
|
|
1760c1b852 | ||
|
|
86d7ab9864 | ||
|
|
f201e4593a | ||
|
|
71129e556e | ||
|
|
0e7ebb79db | ||
|
|
727d9eb57c | ||
|
|
228ff75468 | ||
|
|
80018cac08 | ||
|
|
b2c19e7ed0 | ||
|
|
a64a80910d | ||
|
|
4eeeef3427 | ||
|
|
657586ddff | ||
|
|
797b287899 | ||
|
|
db2d95f2c5 | ||
|
|
16a8f259ab | ||
|
|
ee1cd8d08d | ||
|
|
e53046324c | ||
|
|
dd71977317 | ||
|
|
bee0da1454 | ||
|
|
b60feaa447 | ||
|
|
743e54614a | ||
|
|
bd96713af4 | ||
|
|
8f7f9b457a | ||
|
|
4c0d68eab8 | ||
|
|
8211407cf2 | ||
|
|
5fb6c7d36a | ||
|
|
30b1cf4493 | ||
|
|
f4789ac5fe | ||
|
|
5fd826ae71 | ||
|
|
22ce08f06c | ||
|
|
696c88f03f | ||
|
|
9d5d87e47b | ||
|
|
e32539e02c | ||
|
|
46ba931a6c | ||
|
|
4bac5143d6 | ||
|
|
bcb1674491 | ||
|
|
3cbd9677c2 | ||
|
|
b216ee1a80 | ||
|
|
32ec482449 | ||
|
|
5074748055 | ||
|
|
6fa11f834b | ||
|
|
25c7702127 | ||
|
|
b546556d08 | ||
|
|
379a85f6f6 | ||
|
|
92d2c14d6a | ||
|
|
a6c1f7d3f2 | ||
|
|
6fa6187cc4 | ||
|
|
848e7a1609 | ||
|
|
3e8c0a2896 | ||
|
|
11900b1500 | ||
|
|
85044fe3e0 | ||
|
|
c0499d40ea | ||
|
|
8ee44ffea1 | ||
|
|
5668a1411f | ||
|
|
6db60f111d | ||
|
|
3d695709d5 | ||
|
|
5c682addc9 | ||
|
|
24810cc818 | ||
|
|
8caeb11da5 | ||
|
|
5e07b446fc | ||
|
|
f29444a219 | ||
|
|
bce1c5d9b8 | ||
|
|
5b980fb382 | ||
|
|
e63e62d3e0 | ||
|
|
53f82792d8 | ||
|
|
760388307c | ||
|
|
eebd8500ee | ||
|
|
12112afa37 | ||
|
|
424fa581b9 | ||
|
|
cddb0a7e05 | ||
|
|
83ef0ee74e | ||
|
|
21b317e4f1 | ||
|
|
7d818206b0 | ||
|
|
0216c40bfe | ||
|
|
ecf80280f1 | ||
|
|
6e9568b4fb | ||
|
|
8d968e7d63 | ||
|
|
bb264f0b9b | ||
|
|
be4f770c35 | ||
|
|
17d955dd69 | ||
|
|
3f31e85c06 | ||
|
|
5a0c230ead | ||
|
|
e10350268d | ||
|
|
969a48bef2 | ||
|
|
8158934cb9 | ||
|
|
670f6a1974 | ||
|
|
dd1f791b3e | ||
|
|
91f25bc25d | ||
|
|
d8e9e2dac3 | ||
|
|
67a89a12ac | ||
|
|
faaa4033bd | ||
|
|
f3eedf6f30 | ||
|
|
29718d2a0c | ||
|
|
c7e6bd8b72 | ||
|
|
a67ff12786 | ||
|
|
c92e671622 | ||
|
|
2bd25353da | ||
|
|
e1bee19c18 | ||
|
|
0ec49af72a | ||
|
|
6d4eb836ab | ||
|
|
0c3cfecb1d | ||
|
|
f9186e7354 | ||
|
|
bc603a08cf | ||
|
|
da5c65d5a6 | ||
|
|
077aada056 | ||
|
|
4ed5f9872d | ||
|
|
2d6be20b30 | ||
|
|
be8f3bd98c | ||
|
|
a3a20bee4a | ||
|
|
09a1e75acf | ||
|
|
ce6bdd01ef | ||
|
|
a03a1d635f | ||
|
|
419b6d4891 | ||
|
|
b4840318d5 | ||
|
|
bf81f437d1 | ||
|
|
81a4d33df1 | ||
|
|
baab364b56 | ||
|
|
585ee041ec | ||
|
|
da3d4350cc | ||
|
|
df9390847f | ||
|
|
2a1acbfde8 | ||
|
|
16ef8507b0 | ||
|
|
df5d4e336c | ||
|
|
ad2015329b | ||
|
|
4cc4bed785 | ||
|
|
6c506e6907 | ||
|
|
977f9344c7 | ||
|
|
2adb2f3b68 | ||
|
|
40a6e9e69f | ||
|
|
e1d1a3fb9a | ||
|
|
75120c0a56 | ||
|
|
33f1ef3bb3 | ||
|
|
b6e742333c | ||
|
|
9f8c66fc20 | ||
|
|
3b93257890 | ||
|
|
1104e2a074 | ||
|
|
3e950652e8 | ||
|
|
98e40466a0 | ||
|
|
324d80a735 | ||
|
|
6770be63b6 | ||
|
|
89aa33ebd8 | ||
|
|
619c32f09d | ||
|
|
c471423775 | ||
|
|
c589b9b8b8 | ||
|
|
bae9ba0973 | ||
|
|
7bfc5c1d9b | ||
|
|
8bd3b0d731 | ||
|
|
06cd4de141 | ||
|
|
33eb2df023 | ||
|
|
540ecd4b60 | ||
|
|
237418f009 |
+10
-16
@@ -1,37 +1,31 @@
|
||||
# Environnement
|
||||
NODE_ENV=production
|
||||
NODE_ENV=development
|
||||
|
||||
# Base de données
|
||||
DATABASE_URL=postgresql://devuser:changeme@db:5432/devdb?schema=public
|
||||
DATABASE_URL=postgresql://devuser:changeme@localhost:5433/devdb?schema=public
|
||||
|
||||
# Projet
|
||||
PROJECT_NAME="Portabase"
|
||||
PROJECT_DESCRIPTION="Portabase is a powerful database manager"
|
||||
PROJECT_URL=http://app.portabase.io
|
||||
PROJECT_URL=http://localhost:8887
|
||||
PROJECT_SECRET=
|
||||
|
||||
# SMTP (email)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_PORT=
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=
|
||||
#SMTP_SECURE="true"
|
||||
#Port 587 -> secure: false
|
||||
#Port 465 -> secure: true
|
||||
#By default : true
|
||||
|
||||
# Google
|
||||
AUTH_GOOGLE_ID=
|
||||
AUTH_GOOGLE_SECRET=
|
||||
AUTH_GOOGLE_METHOD=
|
||||
|
||||
# S3
|
||||
S3_ENDPOINT=http://app.s3.portabase.io
|
||||
S3_ACCESS_KEY=
|
||||
S3_SECRET_KEY=
|
||||
S3_BUCKET_NAME=portabase
|
||||
S3_PORT=9000
|
||||
S3_USE_SSL=true
|
||||
|
||||
# Storage Type (s3, local)
|
||||
STORAGE_TYPE=local
|
||||
|
||||
# Retention
|
||||
RETENTION_CRON="* * * * *"
|
||||
RETENTION_CRON="* * * * *"
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: portabase
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
@@ -7,9 +7,9 @@
|
||||
|
||||
We take security seriously and aim to support the following versions of the project with security updates:
|
||||
|
||||
| Version | Supported |
|
||||
|-----------|--------------------|
|
||||
| 1.x | ✅ Fully Supported |
|
||||
| Version | Supported |
|
||||
|---------|--------------------|
|
||||
| Latest | ✅ Fully Supported |
|
||||
|
||||
---
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
@@ -0,0 +1,97 @@
|
||||
name: Docker Publish
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
image_name:
|
||||
required: false
|
||||
type: string
|
||||
default: 'solucetechnologies/portabase'
|
||||
image_name2:
|
||||
required: false
|
||||
type: string
|
||||
default: 'portabase/portabase'
|
||||
add_latest:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
target:
|
||||
required: false
|
||||
type: string
|
||||
default: 'prod'
|
||||
dockerfile:
|
||||
required: false
|
||||
type: string
|
||||
default: './docker/dockerfile/Dockerfile'
|
||||
secrets:
|
||||
DOCKER_USERNAME:
|
||||
required: true
|
||||
DOCKER_PASSWORD:
|
||||
required: true
|
||||
DOCKER_USERNAME_2:
|
||||
required: true
|
||||
DOCKER_PASSWORD_2:
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set tags
|
||||
id: set-tags
|
||||
run: |
|
||||
REF_NAME=${GITHUB_REF#refs/tags/}
|
||||
TAGS="${{ inputs.image_name }}:$REF_NAME"
|
||||
TAGS2="${{ inputs.image_name2 }}:$REF_NAME"
|
||||
if [[ "${{ inputs.add_latest }}" == "true" ]]; then
|
||||
TAGS="$TAGS,${{ inputs.image_name }}:latest"
|
||||
TAGS2="$TAGS2,${{ inputs.image_name2 }}:latest"
|
||||
fi
|
||||
echo "tags=$TAGS" >> $GITHUB_OUTPUT
|
||||
echo "tags2=$TAGS2" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up QEMU (enables multi-arch emulation)
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub account 1
|
||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build and push to solucetechnologies/portabase
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ${{ inputs.dockerfile }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.set-tags.outputs.tags }}
|
||||
target: ${{ inputs.target }}
|
||||
|
||||
- name: Log in to Docker Hub account 2
|
||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME_2 }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD_2 }}
|
||||
|
||||
- name: Build and push to portabase/portabase
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ${{ inputs.dockerfile }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.set-tags.outputs.tags2 }}
|
||||
target: ${{ inputs.target }}
|
||||
@@ -0,0 +1,128 @@
|
||||
name: GitHub Release
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
prerelease:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
make_latest:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
discord_title:
|
||||
required: true
|
||||
type: string
|
||||
discord_color:
|
||||
required: true
|
||||
type: number
|
||||
discord_footer:
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
DISCORD_WEBHOOK:
|
||||
required: true
|
||||
GH_TOKEN:
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Build Changelog
|
||||
id: build_changelog
|
||||
uses: mikepenz/release-changelog-builder-action@v5
|
||||
with:
|
||||
mode: "COMMIT"
|
||||
configurationJson: |
|
||||
{
|
||||
"template": "#{{CHANGELOG}}",
|
||||
"categories": [
|
||||
{
|
||||
"title": "## Feature",
|
||||
"labels": ["feat", "feature"]
|
||||
},
|
||||
{
|
||||
"title": "## Fix",
|
||||
"labels": ["fix", "bug"]
|
||||
},
|
||||
{
|
||||
"title": "## Other",
|
||||
"labels": []
|
||||
}
|
||||
],
|
||||
"label_extractor": [
|
||||
{
|
||||
"pattern": "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test){1}(\([\w\-\.]+\))?(!)?: ([\w ])+([\s\S]*)",
|
||||
"on_property": "title",
|
||||
"target": "$1"
|
||||
}
|
||||
]
|
||||
}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: false
|
||||
body: ${{ steps.build_changelog.outputs.changelog }}
|
||||
prerelease: ${{ inputs.prerelease }}
|
||||
make_latest: ${{ inputs.make_latest }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
- name: Send Discord Notification
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
run: |
|
||||
RELEASE_INFO=$(gh release view "${{ github.ref_name }}" -R ${{ github.repository }} --json name,url,body,author)
|
||||
|
||||
RELEASE_TITLE=$(echo "$RELEASE_INFO" | jq -r .name)
|
||||
if [ -z "$RELEASE_TITLE" ] || [ "$RELEASE_TITLE" = "null" ]; then RELEASE_TITLE="${{ github.ref_name }}"; fi
|
||||
|
||||
RELEASE_URL=$(echo "$RELEASE_INFO" | jq -r .url)
|
||||
RELEASE_BODY=$(echo "$RELEASE_INFO" | jq -r .body)
|
||||
|
||||
AUTHOR_NAME="Portabase"
|
||||
AUTHOR_ICON="https://github.com/Portabase.png"
|
||||
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg title "$RELEASE_TITLE" \
|
||||
--arg description "$RELEASE_BODY" \
|
||||
--arg url "$RELEASE_URL" \
|
||||
--arg author "$AUTHOR_NAME" \
|
||||
--arg icon "$AUTHOR_ICON" \
|
||||
--arg discord_title "${{ inputs.discord_title }}" \
|
||||
--arg discord_footer "${{ inputs.discord_footer }}" \
|
||||
--argjson discord_color ${{ inputs.discord_color }} \
|
||||
'{
|
||||
content: $discord_title,
|
||||
embeds: [{
|
||||
title: $title,
|
||||
url: $url,
|
||||
description: $description,
|
||||
color: $discord_color,
|
||||
author: {
|
||||
name: $author,
|
||||
icon_url: $icon
|
||||
},
|
||||
footer: {
|
||||
text: $discord_footer
|
||||
}
|
||||
}]
|
||||
}'
|
||||
)
|
||||
|
||||
curl -H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" \
|
||||
"$DISCORD_WEBHOOK"
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Publish Docker image for release candidate
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*-rc*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
docker_publish:
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
add_latest: false
|
||||
secrets:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
DOCKER_USERNAME_2: ${{ secrets.DOCKER_USERNAME_2 }}
|
||||
DOCKER_PASSWORD_2: ${{ secrets.DOCKER_PASSWORD_2 }}
|
||||
|
||||
github_release:
|
||||
needs: docker_publish
|
||||
uses: ./.github/workflows/github.yml
|
||||
with:
|
||||
prerelease: true
|
||||
make_latest: false
|
||||
discord_title: "||@release-dashboard|| New release candidate published"
|
||||
discord_color: 16776960
|
||||
discord_footer: "Portabase"
|
||||
secrets:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,50 +1,35 @@
|
||||
name: Publish Docker image for release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
push:
|
||||
tags:
|
||||
- '*.*.*'
|
||||
- '!*-*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
push_to_registry:
|
||||
name: Push Docker image to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
attestations: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
docker_publish:
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
add_latest: true
|
||||
secrets:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
DOCKER_USERNAME_2: ${{ secrets.DOCKER_USERNAME_2 }}
|
||||
DOCKER_PASSWORD_2: ${{ secrets.DOCKER_PASSWORD_2 }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
|
||||
with:
|
||||
images: solucetechnologies/portabase
|
||||
|
||||
- name: Set tags
|
||||
id: set-tags
|
||||
run: |
|
||||
echo "RELEASE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||
TAGS="solucetechnologies/portabase:${GITHUB_REF#refs/tags/}"
|
||||
if [ "${{ github.event.release.target_commitish }}" = "main" ]; then
|
||||
TAGS="$TAGS,solucetechnologies/portabase:latest"
|
||||
fi
|
||||
echo "tags=$TAGS" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/dockerfile/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.set-tags.outputs.tags }}
|
||||
target: prod
|
||||
github_release:
|
||||
needs: docker_publish
|
||||
uses: ./.github/workflows/github.yml
|
||||
with:
|
||||
prerelease: false
|
||||
make_latest: true
|
||||
discord_title: "||@release-dashboard|| New release published"
|
||||
discord_color: 5814783
|
||||
discord_footer: "Portabase"
|
||||
secrets:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,5 +1,6 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
.idea
|
||||
.vscode
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
@@ -41,3 +42,5 @@ public/uploads/*
|
||||
private/uploads/*
|
||||
|
||||
/.env
|
||||
|
||||
certificates
|
||||
Generated
-8
@@ -1,8 +0,0 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
Generated
-8
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/portabase.iml" filepath="$PROJECT_DIR$/.idea/portabase.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
Generated
-12
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/backend" isTestSource="false" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/venv" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
Generated
-6
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
Vendored
-3
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"WillLuke.nextjs.hasPrompted": true
|
||||
}
|
||||
Binary file not shown.
@@ -1,2 +0,0 @@
|
||||
nodeLinker: node-modules
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
cff-version: 1.2.0
|
||||
title: Portabase
|
||||
message: "If you use this software, please cite it as below."
|
||||
type: software
|
||||
authors:
|
||||
- family-names: Gauthereau
|
||||
given-names: Charles
|
||||
- family-names: Larcher
|
||||
given-names: Killian
|
||||
repository-code: https://github.com/Portabase/portabase
|
||||
url: https://portabase.io
|
||||
abstract: "Portabase is a free, open-source, self-hosted solution for database administration, providing backup and restore capabilities, scheduling, retention policies, notifications, and support for multiple storage backends. Its headless agent architecture enables connection to multiple database instances securely and efficiently."
|
||||
keywords:
|
||||
- docker
|
||||
- kubernetes
|
||||
- backups
|
||||
- postgresql
|
||||
- mysql
|
||||
- mariadb
|
||||
- devops
|
||||
- database
|
||||
- monitoring
|
||||
- s3
|
||||
- self-hosted
|
||||
- system-administration
|
||||
- web-ui
|
||||
- agent
|
||||
license: Apache-2.0
|
||||
version: 1.2.5-rc.7
|
||||
date-released: "2026-02-01"
|
||||
@@ -1,29 +1,41 @@
|
||||
<br />
|
||||
<div align="center">
|
||||
<a href="https://portabase.io">
|
||||
<img src="/public/images/logo.png" alt="Logo" width="80" height="80">
|
||||
<img src="/.github/assets/logo.png" alt="Logo" width="80" height="80">
|
||||
</a>
|
||||
|
||||
<h3 align="center">Portabase</h3>
|
||||
<p>
|
||||
Free, open-source, and self-hosted solution for automated backup and restoration of your database instances.
|
||||
|
||||
<p align="center" style="margin-top: 20px; font-style: italic;">
|
||||
<i>Portabase is a tool designed to simplify the backup and restoration of your database instances. It integrates seamlessly with <a href="https://github.com/Portabase/agent">Portabase agents</a> for managing operations securely and efficiently.</i>
|
||||
</p>
|
||||
|
||||
|
||||
[](LICENSE)
|
||||
[](https://hub.docker.com/r/solucetechnologies/portabase)
|
||||
[](https://github.com/RostislavDugin/postgresus)
|
||||
[](https://github.com/Portabase/portabase)
|
||||
[](https://www.buymeacoffee.com/portabase)
|
||||
|
||||
[](https://www.postgresql.org/)
|
||||
[](https://www.mysql.com/)
|
||||
[](https://mariadb.org/)
|
||||
[](https://github.com/RostislavDugin/postgresus)
|
||||
[](https://www.mongodb.com/)
|
||||
[](https://github.com/Portabase/portabase)
|
||||
[](https://github.com/Portabase/portabase)
|
||||
|
||||
|
||||
[![NextJS][NextJS]][NextJS-url]
|
||||
[![BetterAuth][BetterAuth]][BetterAuth-url]
|
||||
[![Drizzle][Drizzle]][Drizzle-url]
|
||||
[![ShadcnUI][ShadcnUI]][ShadcnUI-url]
|
||||
[![Docker][Docker]][Docker-url]
|
||||
|
||||
<p>
|
||||
<strong>
|
||||
<a href="https://portabase.io">Documentation</a> •
|
||||
<a href="https://portabase.io">Website</a> •
|
||||
<a href="https://portabase.io/docs">Documentation</a> •
|
||||
<a href="https://www.youtube.com/watch?v=hvLbX5LN1UE">Demo</a> •
|
||||
<a href="#installation">Installation</a> •
|
||||
<a href="#contributing">Contributing</a> •
|
||||
<a href="https://portabase.io/docs/dashboard/setup">Installation</a> •
|
||||
<a href="https://github.com/Portabase/portabase/issues/new?labels=bug&template=bug-report---.md">Report Bug</a> •
|
||||
<a href="https://github.com/Portabase/portabase/issues/new?labels=enhancement&template=feature-request---.md">Request Feature</a>
|
||||
</strong>
|
||||
@@ -34,258 +46,29 @@
|
||||
|
||||
</div>
|
||||
|
||||
## 📚 Table of Contents
|
||||
## Installation
|
||||
|
||||
- [About The Project](#-about-the-project)
|
||||
- [Getting Started](#-getting-started)
|
||||
- [Usage](#-usage)
|
||||
- [Roadmap](#-roadmap)
|
||||
- [Contributing](#-contributing)
|
||||
- [License](#-license)
|
||||
- [Contact](#-contact)
|
||||
- [Acknowledgments](#-acknowledgments)
|
||||
You have 4 ways to install Portabase:
|
||||
|
||||
---
|
||||
- Automated CLI (recommended) - [details](https://portabase.io/docs/dashboard/setup#cli)
|
||||
- Docker Compose setup - [details](https://portabase.io/docs/dashboard/setup#docker)
|
||||
- Kubernetes with Helm (soon)
|
||||
- Development setup - [details](https://portabase.io/docs/dashboard/setup#development)
|
||||
|
||||
## ✨ About The Project
|
||||
**Ensure Docker is installed on your machine before getting started.**
|
||||
|
||||
**Portabase** is a server dashboard tool designed to simplify the backup and restoration of your database instances. It
|
||||
integrates seamlessly with Portabase agents for managing operations securely and efficiently.
|
||||
|
||||
GitHub Repository: [Portabase](https://github.com/Portabase/portabase)
|
||||
|
||||
### 🔧 Built With
|
||||
|
||||
- [![NextJS][NextJS]][NextJS-url] (v16 with App Router)
|
||||
- [![Drizzle][Drizzle]][Drizzle-url]
|
||||
- [![ShadcnUI][ShadcnUI]][ShadcnUI-url]
|
||||
- [![BetterAuth][BetterAuth]][BetterAuth-url]
|
||||
- [![Docker][Docker]][Docker-url]
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Installation
|
||||
|
||||
Ensure Docker is installed on your machine before getting started.
|
||||
|
||||
### Option 1: Docker Compose Setup
|
||||
|
||||
Create a `docker-compose.yml` file with the following configuration:
|
||||
|
||||
```yaml
|
||||
name: portabase
|
||||
|
||||
services:
|
||||
|
||||
portabase:
|
||||
image: solucetechnologies/portabase:latest
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- '8887:80'
|
||||
environment:
|
||||
- TZ="Europe/Paris"
|
||||
volumes:
|
||||
- portabase-private:/app/private
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
container_name: portabase-app
|
||||
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
environment:
|
||||
- POSTGRES_DB=<your_database>
|
||||
- POSTGRES_USER=<database_user>
|
||||
- POSTGRES_PASSWORD=<database_password>
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -U <database_user> -d <your_database>" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
portabase-private:
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
If you use reverse proxy like
|
||||
Traefik : [Check this link](https://portabase.io/docs/dashboard/advanced/reverse-proxy)
|
||||
|
||||
### Option 2: Locally (Development)
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/Portabase/portabase
|
||||
cd portabase
|
||||
```
|
||||
2. Start the development environment:
|
||||
```bash
|
||||
docker compose up
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Usage
|
||||
|
||||
Portabase provides a web dashboard to manage your database instances and backups.
|
||||
It currently supports:
|
||||
|
||||
- **PostgreSQL**
|
||||
- **MySQL**
|
||||
|
||||
### Process
|
||||
|
||||
1. **Access the dashboard** – Open `http://localhost:8887` in your browser.
|
||||
2. **Sign up** – Register the first user, who will automatically have the **Admin** role in the default workspace.
|
||||
3. **Add your first agent** – Follow [this guide](https://github.com/Portabase/agent-portabase) for setup
|
||||
instructions.
|
||||
4. **Create organizations and projects** – Link your databases to projects to enable backups and restores.
|
||||
5. **Configure backup policies** – Define schedules (hourly, daily, weekly, or monthly) and retention rules.
|
||||
6. **Choose a storage provider** – Select where backups will be stored (local, S3, etc.).
|
||||
7. **Save and start** – Portabase validates your configuration and starts automated backups based on your defined
|
||||
policies.
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
- [ ] Add changelog
|
||||
- [ ] Enhance documentation
|
||||
- [ ] Implement testing procedures
|
||||
- [ ] Include release file
|
||||
- [ ] Improve security
|
||||
- [ ] Enhance UX/UI
|
||||
- [ ] Improve workspace management system
|
||||
- [x] Migrate to Drizzle ORM
|
||||
- [ ] Add multi-language support
|
||||
- [ ] Extend multi-database support:
|
||||
- [x] PostgreSQL
|
||||
- [ ] MongoDB
|
||||
- [x] MySQL
|
||||
- [x] MariaDB
|
||||
|
||||
Check out [open issues](https://github.com/Portabase/portabase/issues) for more.
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome and appreciated! Here's how to get started:
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a new branch:
|
||||
```bash
|
||||
git checkout -b feature/YourFeature
|
||||
```
|
||||
3. Commit your changes:
|
||||
```bash
|
||||
git commit -m "Add YourFeature"
|
||||
```
|
||||
4. Push to the branch:
|
||||
```bash
|
||||
git push origin feature/YourFeature
|
||||
```
|
||||
5. Open a pull request
|
||||
|
||||
Give the project a ⭐ if you like it!
|
||||
|
||||
### Top Contributors
|
||||
## Contributors
|
||||
|
||||
[](https://github.com/Portabase/portabase/graphs/contributors)
|
||||
|
||||
---
|
||||
[](https://www.buymeacoffee.com/portabase)
|
||||
|
||||
## Developer Notes
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```yml
|
||||
# Environment
|
||||
NODE_ENV=production
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://devuser:changeme@db:5432/devdb?schema=public
|
||||
|
||||
# Project Info
|
||||
PROJECT_NAME="Portabase"
|
||||
PROJECT_DESCRIPTION="Portabase is a powerful database manager"
|
||||
PROJECT_URL=http://app.portabase.io
|
||||
PROJECT_SECRET=
|
||||
|
||||
# SMTP (Email)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=
|
||||
|
||||
# Google OAuth
|
||||
AUTH_GOOGLE_ID=
|
||||
AUTH_GOOGLE_SECRET=
|
||||
AUTH_GOOGLE_METHOD=
|
||||
|
||||
# S3/MinIO Configuration
|
||||
S3_ENDPOINT=http://app.s3.portabase.io
|
||||
S3_ACCESS_KEY=
|
||||
S3_SECRET_KEY=
|
||||
S3_BUCKET_NAME=portabase
|
||||
S3_PORT=9000
|
||||
S3_USE_SSL=true
|
||||
|
||||
# Storage Backend: 'local' or 's3'
|
||||
STORAGE_TYPE=local
|
||||
|
||||
# Retention
|
||||
RETENTION_CRON="* * * * *"
|
||||
```
|
||||
|
||||
To get more information about env variables, check
|
||||
that [link](https://portabase.io/docs/portabase/advanced-topics/environment)
|
||||
|
||||
### Semantic Versioning
|
||||
|
||||
Use the following format for Docker image versioning:
|
||||
|
||||
```bash
|
||||
major.minor.patch-rc.release
|
||||
# Example: 1.0.0-rc.1
|
||||
|
||||
major.minor.patch-rc.release-tag
|
||||
# Example: 1.0.0-rc.1-dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
## License
|
||||
|
||||
Distributed under the Apache License. See `LICENSE.txt` for more details.
|
||||
|
||||
---
|
||||
|
||||
## 📬 Contact
|
||||
|
||||
- Killian Larcher - killian.larcher@soluce-technologies.com
|
||||
- Charles Gauthereau - charles.gauthereau@soluce-technologies.com
|
||||
- Project Link: [Portabase GitHub](https://github.com/Portabase/portabase)
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
Thanks to all contributors and the open-source community!
|
||||
|
||||
[Docker]: https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=fff&style=for-the-badge
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ import {CardAuth} from "@/features/layout/card-auth";
|
||||
export default async function GuardPage() {
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get("better-auth.two_factor")?.value;
|
||||
|
||||
const token = cookieStore.get("better-auth.two_factor")?.value || cookieStore.get("__Secure-better-auth.two_factor")?.value;
|
||||
|
||||
if (!token) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {db} from "@/db";
|
||||
import {notFound} from "next/navigation";
|
||||
import {SettingsTabs} from "@/components/wrappers/dashboard/admin/settings/settings-tabs";
|
||||
import {desc, isNull} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
const settings = await db.query.setting.findFirst({
|
||||
where: (fields, {eq}) => eq(fields.name, "system"),
|
||||
});
|
||||
|
||||
const storageChannels = await db.query.storageChannel.findMany({
|
||||
with: {
|
||||
organizations: true
|
||||
},
|
||||
where: isNull(drizzleDb.schemas.storageChannel.organizationId),
|
||||
orderBy: desc(drizzleDb.schemas.storageChannel.createdAt)
|
||||
}) as StorageChannelWith[]
|
||||
|
||||
if (!settings || !storageChannels ) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader className="flex flex-col">
|
||||
<div className="flex justify-between">
|
||||
<PageTitle className="mb-3">System settings</PageTitle>
|
||||
</div>
|
||||
</PageHeader>
|
||||
<PageContent className="flex flex-col gap-5">
|
||||
<SettingsTabs storageChannels={storageChannels} settings={settings} />
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { PageParams } from "@/types/next";
|
||||
import { Page, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
|
||||
import { AgentForm } from "@/components/wrappers/dashboard/agent/agent-form/agent-form";
|
||||
import { notFound } from "next/navigation";
|
||||
import { db } from "@/db";
|
||||
|
||||
export default async function RoutePage(
|
||||
props: PageParams<{
|
||||
agentId: string;
|
||||
}>
|
||||
) {
|
||||
const { agentId } = await props.params;
|
||||
|
||||
const agent = await db.query.agent.findFirst({
|
||||
where: (fields, { eq }) => eq(fields.id, agentId),
|
||||
});
|
||||
|
||||
if (!agent) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle>Edit {agent.name}</PageTitle>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<AgentForm defaultValues={agent} agentId={agent.id} />
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import {GearIcon} from "@radix-ui/react-icons";
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageActions, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
||||
import {buttonVariants} from "@/components/ui/button";
|
||||
import {Page, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
@@ -12,6 +9,8 @@ import {capitalizeFirstLetter} from "@/utils/text";
|
||||
import {generateEdgeKey} from "@/utils/edge_key";
|
||||
import {getServerUrl} from "@/utils/get-server-url";
|
||||
import {AgentContentPage} from "@/components/wrappers/dashboard/agent/agent-content";
|
||||
import {AgentDialog} from "@/features/agents/components/agent.dialog";
|
||||
import {AgentType} from "@/features/agents/agents.schema";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{ agentId: string }>) {
|
||||
|
||||
@@ -33,16 +32,19 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
|
||||
return (
|
||||
<Page>
|
||||
<div className="justify-between gap-2 sm:flex">
|
||||
<PageTitle className="flex items-center">
|
||||
{capitalizeFirstLetter(agent.name)}
|
||||
<Link className={buttonVariants({variant: "outline"})}
|
||||
href={`/dashboard/agents/${agent.id}/edit`}>
|
||||
<GearIcon className="w-7 h-7"/>
|
||||
</Link>
|
||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||
<div className="min-w-full md:min-w-fit ">
|
||||
{capitalizeFirstLetter(agent.name)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||
<div className="flex items-center gap-2">
|
||||
<AgentDialog agent={agent as AgentType & { id: string }} typeTrigger={"edit"}/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"}/>
|
||||
</div>
|
||||
</div>
|
||||
</PageTitle>
|
||||
<PageActions className="justify-between">
|
||||
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"}/>
|
||||
</PageActions>
|
||||
</div>
|
||||
|
||||
{agent.description && (
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {AgentForm} from "@/components/wrappers/dashboard/agent/agent-form/agent-form";
|
||||
import {Metadata} from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Agent",
|
||||
};
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle>Create new agent</PageTitle>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<AgentForm/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {AgentCard} from "@/components/wrappers/dashboard/agent/agent-card/agent-card";
|
||||
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {notFound} from "next/navigation";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {eq, not} from "drizzle-orm";
|
||||
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
|
||||
import {desc, eq, not} from "drizzle-orm";
|
||||
import {Metadata} from "next";
|
||||
import {AgentDialog} from "@/features/agents/components/agent.dialog";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Agents",
|
||||
@@ -21,12 +19,10 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
where: not(eq(drizzleDb.schemas.agent.isArchived, true)),
|
||||
with: {
|
||||
databases: true
|
||||
}
|
||||
},
|
||||
orderBy: (fields) => desc(fields.lastContact),
|
||||
});
|
||||
|
||||
console.log(agents);
|
||||
|
||||
|
||||
if (!agents) {
|
||||
notFound();
|
||||
}
|
||||
@@ -37,9 +33,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
<PageTitle>Agents</PageTitle>
|
||||
{agents.length > 0 && (
|
||||
<PageActions>
|
||||
<Link href={"/dashboard/agents/new"}>
|
||||
<Button>+ Create Agent</Button>
|
||||
</Link>
|
||||
<AgentDialog typeTrigger={"create"} />
|
||||
</PageActions>
|
||||
)}
|
||||
</PageHeader>
|
||||
@@ -47,12 +41,9 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
{agents.length > 0 ? (
|
||||
<CardsWithPagination data={agents} cardItem={AgentCard} cardsPerPage={4} numberOfColumns={1}/>
|
||||
) : (
|
||||
<EmptyStatePlaceholder
|
||||
url={"/dashboard/agents/new"}
|
||||
text={"Create new Agent"}
|
||||
/>
|
||||
<AgentDialog typeTrigger="empty"/>
|
||||
)}
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {Metadata} from "next";
|
||||
import {
|
||||
NotificationChannelsSection
|
||||
} from "@/components/wrappers/dashboard/admin/notifications/channels/notification-channels-section";
|
||||
import {db} from "@/db";
|
||||
import {notificationChannel, NotificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
||||
import {NotifierAddEditModal} from "@/components/wrappers/dashboard/common/notifier/notifier-add-edit-modal";
|
||||
import {notificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
||||
import {desc, isNull} from "drizzle-orm";
|
||||
import {ChannelsSection} from "@/components/wrappers/dashboard/admin/channels/channels-section";
|
||||
import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal";
|
||||
import * as drizzleDb from "@/db";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Notification Channels",
|
||||
@@ -19,6 +18,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
with: {
|
||||
organizations: true
|
||||
},
|
||||
where: isNull(drizzleDb.schemas.notificationChannel.organizationId),
|
||||
orderBy: desc(notificationChannel.createdAt)
|
||||
}) as NotificationChannelWith[]
|
||||
|
||||
@@ -35,11 +35,14 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
<PageHeader>
|
||||
<PageTitle>Notification channels</PageTitle>
|
||||
<PageActions>
|
||||
<NotifierAddEditModal adminView={false}/>
|
||||
{/*<NotifierAddEditModal adminView={false}/>*/}
|
||||
<ChannelAddEditModal kind="notification" adminView={false}/>
|
||||
</PageActions>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<NotificationChannelsSection organizations={organizations} notificationChannels={notificationChannels}/>
|
||||
{/*<NotificationChannelsSection organizations={organizations} notificationChannels={notificationChannels}/>*/}
|
||||
<ChannelsSection kind="notification" organizations={organizations}
|
||||
channels={notificationChannels}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {Metadata} from "next";
|
||||
import {ChannelsSection} from "@/components/wrappers/dashboard/admin/channels/channels-section";
|
||||
import {db} from "@/db";
|
||||
import {desc, eq, isNull} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
||||
import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Storage Channels",
|
||||
};
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
const storageChannels = await db.query.storageChannel.findMany({
|
||||
with: {
|
||||
organizations: true
|
||||
},
|
||||
where: isNull(drizzleDb.schemas.storageChannel.organizationId),
|
||||
orderBy: desc(drizzleDb.schemas.storageChannel.createdAt)
|
||||
}) as StorageChannelWith[]
|
||||
|
||||
const organizations = await db.query.organization.findMany({
|
||||
where: (fields) => isNull(fields.deletedAt),
|
||||
with: {
|
||||
members: true,
|
||||
},
|
||||
});
|
||||
|
||||
const settings = await db.query.setting.findFirst({
|
||||
where: eq(drizzleDb.schemas.setting.name, "system"),
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle>Storage channels</PageTitle>
|
||||
<PageActions>
|
||||
<ChannelAddEditModal kind={"storage"} adminView={false}/>
|
||||
</PageActions>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<ChannelsSection defaultStorageChannelId={settings?.defaultStorageChannelId} kind={"storage"} organizations={organizations} channels={storageChannels}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
+41
-12
@@ -2,7 +2,6 @@ import {PageParams} from "@/types/next";
|
||||
import {notFound, redirect} from "next/navigation";
|
||||
import {Page, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
||||
import {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
|
||||
import {DatabaseTabs} from "@/components/wrappers/dashboard/projects/database/database-tabs";
|
||||
import {DatabaseKpi} from "@/components/wrappers/dashboard/projects/database/database-kpi";
|
||||
import {CronButton} from "@/components/wrappers/dashboard/database/cron-button/cron-button";
|
||||
import {db} from "@/db";
|
||||
@@ -12,9 +11,13 @@ import {getOrganizationProjectDatabases} from "@/lib/services";
|
||||
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
||||
import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet";
|
||||
import {capitalizeFirstLetter} from "@/utils/text";
|
||||
import {AlertPolicyModal} from "@/components/wrappers/dashboard/database/alert-policy/alert-policy-modal";
|
||||
import {getOrganizationChannels} from "@/db/services/notification-channel";
|
||||
import {ImportModal} from "@/components/wrappers/dashboard/database/import/import-modal";
|
||||
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
|
||||
import {ChannelPoliciesModal} from "@/components/wrappers/dashboard/database/channels-policy/policy-modal";
|
||||
import {HardDrive, Megaphone} from "lucide-react";
|
||||
import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
|
||||
import {DatabaseContent} from "@/components/wrappers/dashboard/projects/database/database-content";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{
|
||||
projectId: string;
|
||||
@@ -39,7 +42,8 @@ export default async function RoutePage(props: PageParams<{
|
||||
with: {
|
||||
project: true,
|
||||
retentionPolicy: true,
|
||||
alertPolicies: true
|
||||
alertPolicies: true,
|
||||
storagePolicies: true
|
||||
}
|
||||
});
|
||||
|
||||
@@ -51,6 +55,11 @@ export default async function RoutePage(props: PageParams<{
|
||||
where: eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
|
||||
with: {
|
||||
restorations: true,
|
||||
storages: {
|
||||
with: {
|
||||
storageChannel: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: (b, {desc}) => [desc(b.createdAt)],
|
||||
});
|
||||
@@ -60,7 +69,7 @@ export default async function RoutePage(props: PageParams<{
|
||||
orderBy: (r, {desc}) => [desc(r.createdAt)],
|
||||
});
|
||||
|
||||
const isAlreadyBackup = backups.some((b) => b.status === "waiting");
|
||||
const isAlreadyBackup = backups.some((b) => b.status === "waiting" || b.status === "ongoing");
|
||||
const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
|
||||
|
||||
const totalBackups = await db.select({count: drizzleDb.schemas.backup.id})
|
||||
@@ -87,6 +96,10 @@ export default async function RoutePage(props: PageParams<{
|
||||
const organizationChannels = await getOrganizationChannels(organization.id);
|
||||
const activeOrganizationChannels = organizationChannels.filter(channel => channel.enabled);
|
||||
|
||||
const organizationStorageChannels = await getOrganizationStorageChannels(organization.id);
|
||||
const activeOrganizationStorageChannels = organizationStorageChannels.filter(channel => channel.enabled);
|
||||
|
||||
|
||||
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
|
||||
|
||||
const isMember = activeMember?.role === "member";
|
||||
@@ -102,12 +115,22 @@ export default async function RoutePage(props: PageParams<{
|
||||
{!isMember && (
|
||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Do not delete*/}
|
||||
{/*<EditButton/>*/}
|
||||
<RetentionPolicySheet database={dbItem}/>
|
||||
<CronButton database={dbItem}/>
|
||||
<AlertPolicyModal database={dbItem} notificationChannels={activeOrganizationChannels}
|
||||
organizationId={organization.id}/>
|
||||
<ChannelPoliciesModal
|
||||
database={dbItem}
|
||||
kind={"notification"}
|
||||
icon={<Megaphone/>}
|
||||
channels={activeOrganizationChannels}
|
||||
organizationId={organization.id}
|
||||
/>
|
||||
<ChannelPoliciesModal
|
||||
database={dbItem}
|
||||
icon={<HardDrive/>}
|
||||
kind={"storage"}
|
||||
channels={activeOrganizationStorageChannels}
|
||||
organizationId={organization.id}
|
||||
/>
|
||||
<ImportModal database={dbItem}/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -126,10 +149,16 @@ export default async function RoutePage(props: PageParams<{
|
||||
<PageContent className="flex flex-col w-full h-full">
|
||||
<DatabaseKpi successRate={successRate} database={dbItem} availableBackups={availableBackups}
|
||||
totalBackups={totalBackups}/>
|
||||
<DatabaseTabs activeMember={activeMember} settings={settings} database={dbItem}
|
||||
isAlreadyRestore={isAlreadyRestore}
|
||||
backups={backups}
|
||||
restorations={restorations}/>
|
||||
<BackupModalProvider>
|
||||
<DatabaseContent
|
||||
activeMember={activeMember}
|
||||
settings={settings}
|
||||
database={dbItem}
|
||||
isAlreadyRestore={isAlreadyRestore}
|
||||
restorations={restorations}
|
||||
backups={backups}
|
||||
/>
|
||||
</BackupModalProvider>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import {notFound} from "next/navigation";
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {ProjectForm} from "@/components/wrappers/dashboard/projects/project-form/project-form";
|
||||
import * as drizzleDb from "@/db";
|
||||
|
||||
import {db} from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {DatabaseWith} from "@/db/schema/07_database";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{ projectId: string }>) {
|
||||
const {projectId} = await props.params;
|
||||
|
||||
const proj = await db.query.project.findFirst({
|
||||
where: eq(drizzleDb.schemas.project.id, projectId),
|
||||
with: {
|
||||
databases: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!proj) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
|
||||
const org = await db.query.organization.findFirst({
|
||||
where: eq(drizzleDb.schemas.organization.slug, "default"),
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const availableDatabases = (
|
||||
await db.query.database.findMany({
|
||||
where: (db, {or, eq, isNull}) => or(isNull(db.projectId), eq(db.projectId, proj.id)),
|
||||
with: {
|
||||
agent: true,
|
||||
project: true,
|
||||
backups: true,
|
||||
restorations: true,
|
||||
},
|
||||
orderBy: (db, {desc}) => [desc(db.createdAt)],
|
||||
})
|
||||
)
|
||||
// .filter((db) => db.project !== null) as DatabaseWith[];
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle>Edit {proj.name}</PageTitle>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<ProjectForm
|
||||
organization={org}
|
||||
databases={availableDatabases as DatabaseWith[]}
|
||||
defaultValues={{...proj, databases: proj.databases.map((db) => db.id)}}
|
||||
projectId={proj.id}
|
||||
/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageActions, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
||||
import {buttonVariants} from "@/components/ui/button";
|
||||
import {GearIcon} from "@radix-ui/react-icons";
|
||||
import Link from "next/link";
|
||||
import {Page, PageContent, PageTitle} from "@/features/layout/page";
|
||||
import {
|
||||
ButtonDeleteProject
|
||||
} from "@/components/wrappers/dashboard/projects/button-delete-project/button-delete-project";
|
||||
@@ -15,6 +12,11 @@ import {eq} from "drizzle-orm";
|
||||
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {capitalizeFirstLetter} from "@/utils/text";
|
||||
import {ProjectDialog} from "@/features/projects/components/project.dialog";
|
||||
import {DatabaseWith} from "@/db/schema/07_database";
|
||||
import {ProjectWith} from "@/db/schema/06_project";
|
||||
import {isUuidv4} from "@/utils/verify-uuid";
|
||||
|
||||
|
||||
export default async function RoutePage(props: PageParams<{
|
||||
projectId: string
|
||||
@@ -23,6 +25,10 @@ export default async function RoutePage(props: PageParams<{
|
||||
projectId
|
||||
} = await props.params;
|
||||
|
||||
if (!isUuidv4(projectId)) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const organization = await getOrganization({});
|
||||
const activeMember = await getActiveMember()
|
||||
|
||||
@@ -50,27 +56,45 @@ export default async function RoutePage(props: PageParams<{
|
||||
redirect("/dashboard/projects");
|
||||
}
|
||||
|
||||
const availableDatabases = (
|
||||
await db.query.database.findMany({
|
||||
where: (db, {or, eq, isNull}) => or(isNull(db.projectId), eq(db.projectId, proj.id)),
|
||||
with: {
|
||||
agent: true,
|
||||
project: true,
|
||||
backups: true,
|
||||
restorations: true,
|
||||
},
|
||||
orderBy: (db, {desc}) => [desc(db.createdAt)],
|
||||
})
|
||||
) as DatabaseWith[];
|
||||
|
||||
const isMember = activeMember?.role === "member";
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="justify-between gap-2 sm:flex">
|
||||
<PageTitle className="flex items-center">
|
||||
{capitalizeFirstLetter(proj.name)}
|
||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||
<div className="min-w-full md:min-w-fit ">
|
||||
{capitalizeFirstLetter(proj.name)}
|
||||
</div>
|
||||
{!isMember && (
|
||||
<Link className={buttonVariants({variant: "outline"})}
|
||||
href={`/dashboard/projects/${proj.id}/edit`}>
|
||||
<GearIcon className="w-7 h-7"/>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProjectDialog
|
||||
databases={availableDatabases}
|
||||
organization={org}
|
||||
project={proj as ProjectWith}
|
||||
isEdit={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ButtonDeleteProject projectId={projectId} text={"Delete Project"}/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageTitle>
|
||||
{!isMember && (
|
||||
<PageActions className="justify-between">
|
||||
<ButtonDeleteProject projectId={projectId} text={"Delete Project"}/>
|
||||
</PageActions>
|
||||
)}
|
||||
</div>
|
||||
<PageDescription>The list of associated databases</PageDescription>
|
||||
<PageContent className="flex flex-col w-full h-full">
|
||||
{proj.databases.length > 0 ? (
|
||||
<CardsWithPagination
|
||||
@@ -78,8 +102,8 @@ export default async function RoutePage(props: PageParams<{
|
||||
organizationSlug={organization.slug}
|
||||
// @ts-ignore
|
||||
cardItem={ProjectDatabaseCard}
|
||||
cardsPerPage={4}
|
||||
numberOfColumns={1}
|
||||
cardsPerPage={6}
|
||||
numberOfColumns={3}
|
||||
extendedProps={proj}
|
||||
/>
|
||||
) : (
|
||||
@@ -91,4 +115,4 @@ export default async function RoutePage(props: PageParams<{
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {ProjectForm} from "@/components/wrappers/dashboard/projects/project-form/project-form";
|
||||
import {notFound} from "next/navigation";
|
||||
import {db} from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {getOrganization} from "@/lib/auth/auth";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {DatabaseWith} from "@/db/schema/07_database";
|
||||
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
const organization = await getOrganization({});
|
||||
|
||||
if (!organization) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const availableDatabases = (
|
||||
await db.query.database.findMany({
|
||||
where: (db, {isNull}) => isNull(db.projectId),
|
||||
with: {
|
||||
agent: true,
|
||||
project: true,
|
||||
backups: true,
|
||||
restorations: true,
|
||||
},
|
||||
orderBy: (db, {desc}) => [desc(db.createdAt)],
|
||||
})
|
||||
).filter((db) => db.project == null) as DatabaseWith[];
|
||||
|
||||
const org = await db.query.organization.findFirst({
|
||||
where: eq(drizzleDb.schemas.organization.slug, organization.slug),
|
||||
});
|
||||
|
||||
if (!org) notFound();
|
||||
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle>Create new project</PageTitle>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<ProjectForm databases={availableDatabases} organization={org}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import {PageParams} from "@/types/next";
|
||||
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {ProjectCard} from "@/components/wrappers/dashboard/projects/project-card/project-card";
|
||||
import {db} from "@/db";
|
||||
@@ -10,6 +7,8 @@ import {notFound} from "next/navigation";
|
||||
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
||||
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
|
||||
import {Metadata} from "next";
|
||||
import {ProjectDialog} from "@/features/projects/components/project.dialog";
|
||||
import {DatabaseWith} from "@/db/schema/07_database";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Projects",
|
||||
@@ -36,6 +35,19 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
});
|
||||
const isMember = activeMember?.role === "member";
|
||||
|
||||
const availableDatabases = (
|
||||
await db.query.database.findMany({
|
||||
where: (db, {isNull}) => isNull(db.projectId),
|
||||
with: {
|
||||
agent: true,
|
||||
project: true,
|
||||
backups: true,
|
||||
restorations: true,
|
||||
},
|
||||
orderBy: (db, {desc}) => [desc(db.createdAt)],
|
||||
})
|
||||
).filter((db) => db.project == null) as DatabaseWith[];
|
||||
|
||||
|
||||
return (
|
||||
<Page>
|
||||
@@ -43,9 +55,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
<PageTitle>Projects</PageTitle>
|
||||
{(projects.length > 0 && !isMember) && (
|
||||
<PageActions>
|
||||
<Link href={`/dashboard/projects/new`}>
|
||||
<Button>+ Create Project</Button>
|
||||
</Link>
|
||||
<ProjectDialog databases={availableDatabases} organization={organization}/>
|
||||
</PageActions>
|
||||
)}
|
||||
</PageHeader>
|
||||
@@ -56,15 +66,15 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
organizationSlug={organization.slug}
|
||||
data={projects}
|
||||
cardItem={ProjectCard}
|
||||
cardsPerPage={4}
|
||||
numberOfColumns={1}
|
||||
cardsPerPage={6}
|
||||
numberOfColumns={3}
|
||||
/>
|
||||
) : isMember ? (
|
||||
<EmptyStatePlaceholder text="No project available"/>
|
||||
) : (
|
||||
<EmptyStatePlaceholder url="/dashboard/projects/new" text="Create new Project"/>
|
||||
<ProjectDialog databases={availableDatabases} organization={organization} isEmpty={true}/>
|
||||
)}
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {notFound} from "next/navigation";
|
||||
import {OrganizationForm} from "@/components/wrappers/dashboard/organization/organization-form/organization-form";
|
||||
import {getOrganization} from "@/lib/auth/auth";
|
||||
import {db} from "@/db";
|
||||
import {isNull} from "drizzle-orm";
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{
|
||||
|
||||
}>) {
|
||||
const user = await currentUser();
|
||||
|
||||
const organization = await getOrganization({});
|
||||
|
||||
const users = await db.query.user.findMany({
|
||||
where: (fields) => isNull(fields.deletedAt)
|
||||
});
|
||||
|
||||
if (!user || !users || !organization || organization.slug == "default") {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return(
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle>
|
||||
Edit {organization.name}
|
||||
</PageTitle>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<OrganizationForm currentUser={user} users={users} defaultValues={organization}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
@@ -3,14 +3,19 @@ import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
||||
import {notFound} from "next/navigation";
|
||||
import {
|
||||
DeleteOrganizationButton
|
||||
} from "@/components/wrappers/dashboard/organization/delete-organization/delete-organization-button";
|
||||
import {EditButtonSettings} from "@/components/wrappers/dashboard/settings/edit-button-settings/edit-button-settings";
|
||||
import {Metadata} from "next";
|
||||
import {OrganizationTabs} from "@/components/wrappers/dashboard/organization/tabs/organization-tabs";
|
||||
import {getOrganizationChannels} from "@/db/services/notification-channel";
|
||||
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
|
||||
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
|
||||
import {DeleteOrganizationButton} from "@/components/wrappers/dashboard/organization/delete-organization-button";
|
||||
import {EditOrganizationDialog} from "@/features/organization/components/edit-organization.dialog";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {GearIcon} from "@radix-ui/react-icons";
|
||||
import {db} from "@/db";
|
||||
import {isNull} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Settings",
|
||||
@@ -21,38 +26,64 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
||||
const user = await currentUser();
|
||||
const activeMember = await getActiveMember()
|
||||
|
||||
if (!organization || !activeMember) {
|
||||
if (!organization || !activeMember || !user) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const notificationChannels = await getOrganizationChannels(organization.id)
|
||||
|
||||
const storageChannels = await getOrganizationStorageChannels(organization.id)
|
||||
const permissions = computeOrganizationPermissions(activeMember);
|
||||
|
||||
const users = await db.query.user.findMany({
|
||||
where: (fields) => isNull(fields.deletedAt)
|
||||
});
|
||||
|
||||
const organizationWithMembers = await db.query.organization.findFirst({
|
||||
where: eq(drizzleDb.schemas.organization.id, organization.id),
|
||||
with: {
|
||||
members: {
|
||||
with: {
|
||||
user: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!organizationWithMembers) notFound();
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle className="flex items-center">
|
||||
Organization settings
|
||||
{permissions.canManageSettings && organization.slug !== "default" && (
|
||||
<EditButtonSettings/>
|
||||
)}
|
||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||
<div className="min-w-full md:min-w-fit ">
|
||||
Organization settings
|
||||
</div>
|
||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||
<div className="flex items-center gap-2">
|
||||
{permissions.canManageSettings && organization.slug !== "default" && (
|
||||
<EditOrganizationDialog
|
||||
organization={organizationWithMembers}
|
||||
users={users}
|
||||
currentUser={user}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{permissions.canManageDangerZone && organization.slug !== "default" && (
|
||||
<DeleteOrganizationButton organizationSlug={organization.slug}/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageTitle>
|
||||
|
||||
<PageActions>
|
||||
{permissions.canManageDangerZone && organization.slug !== "default" && (
|
||||
<DeleteOrganizationButton organizationSlug={organization.slug}/>
|
||||
)}
|
||||
</PageActions>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<OrganizationTabs
|
||||
activeMember={activeMember}
|
||||
organization={organization}
|
||||
notificationChannels={notificationChannels}
|
||||
storageChannels={storageChannels}
|
||||
/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {AppSidebar} from "@/components/wrappers/dashboard/common/sidebar/app-sid
|
||||
import {Header} from "@/features/layout/Header";
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
import {ThemeMetaUpdater} from "@/features/browser/theme-meta-updater";
|
||||
import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
|
||||
|
||||
export default async function Layout({children}: { children: ReactNode }) {
|
||||
const user = await currentUser();
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import fs from "node:fs";
|
||||
import forge from "node-forge";
|
||||
import {EventPayload} from "@/features/notifications/types";
|
||||
import {dispatchNotification} from "@/features/notifications/dispatch";
|
||||
import {Database, DatabaseWith} from "@/db/schema/07_database";
|
||||
|
||||
|
||||
export async function decryptedDump(file: File, aesKeyHex: string, ivHex: string, fileExtension: string): Promise<File> {
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import {NextResponse} from "next/server";
|
||||
import {isUuidv4} from "@/utils/verify-uuid";
|
||||
import {uploadLocalPrivate, uploadS3Private} from "@/features/upload/private/upload.action";
|
||||
import {v4 as uuidv4} from "uuid";
|
||||
import {eventEmitter} from "../../../events/route";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {db} from "@/db";
|
||||
import {Backup} from "@/db/schema/07_database";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
import {env} from "@/env.mjs";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
import {decryptedDump, getFileExtension} from "./helpers";
|
||||
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
|
||||
import {storeBackupFiles} from "@/features/storages/helpers";
|
||||
import {eventEmitter} from "@/features/shared/event";
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
@@ -25,6 +24,7 @@ export async function POST(
|
||||
{status: 400}
|
||||
);
|
||||
}
|
||||
|
||||
eventEmitter.emit('modification', {update: true});
|
||||
|
||||
const agentId = (await params).agentId;
|
||||
@@ -34,6 +34,7 @@ export async function POST(
|
||||
const generatedId = formData.get("generatedId") as string | null;
|
||||
const method = formData.get("method") as string | null;
|
||||
|
||||
|
||||
if (!generatedId || !isUuidv4(generatedId)) {
|
||||
return NextResponse.json(
|
||||
{error: "generatedId is not a valid UUID"},
|
||||
@@ -56,7 +57,8 @@ export async function POST(
|
||||
where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId),
|
||||
with: {
|
||||
project: true,
|
||||
alertPolicies: true
|
||||
alertPolicies: true,
|
||||
storagePolicies: true
|
||||
}
|
||||
});
|
||||
|
||||
@@ -106,7 +108,7 @@ export async function POST(
|
||||
|
||||
if (status === "success") {
|
||||
const file = formData.get("file") as File | null;
|
||||
|
||||
const extension = formData.get("extension") as string | null;
|
||||
if (!aesKeyHex || !ivHex) {
|
||||
return NextResponse.json({error: "Missing fields"}, {status: 400});
|
||||
}
|
||||
@@ -118,41 +120,16 @@ export async function POST(
|
||||
{status: 400}
|
||||
);
|
||||
}
|
||||
const fileExtension = getFileExtension(database.dbms)
|
||||
const fileSizeBytes = file.size;
|
||||
// const fileExtension = '.' + (file.name.split('.').pop()?.toLowerCase() || '');
|
||||
const fileExtension = extension ? extension : getFileExtension(database.dbms)
|
||||
const decryptedFile = await decryptedDump(file, aesKeyHex, ivHex, fileExtension);
|
||||
const uuid = uuidv4();
|
||||
const fileName = `${uuid}${fileExtension}`;
|
||||
const buffer = Buffer.from(await decryptedFile.arrayBuffer());
|
||||
|
||||
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
|
||||
if (!settings) {
|
||||
throw new Error("System settings not found.");
|
||||
}
|
||||
|
||||
let success: boolean, message: string, filePath: string;
|
||||
|
||||
const result =
|
||||
settings.storage === "local"
|
||||
? await uploadLocalPrivate(fileName, buffer)
|
||||
: await uploadS3Private(`${database.project?.slug}/${fileName}`, buffer, env.S3_BUCKET_NAME!);
|
||||
|
||||
({success, message, filePath} = result);
|
||||
|
||||
if (!success) {
|
||||
return NextResponse.json(
|
||||
{error: message},
|
||||
{status: 500}
|
||||
);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(drizzleDb.schemas.backup)
|
||||
.set(withUpdatedAt({
|
||||
file: fileName,
|
||||
status: 'success',
|
||||
}))
|
||||
.where(eq(drizzleDb.schemas.backup.id, backup.id));
|
||||
|
||||
const storageResults = await storeBackupFiles(backup, database, buffer, fileName)
|
||||
eventEmitter.emit('modification', {update: true});
|
||||
|
||||
await sendNotificationsBackupRestore(database, "success_backup");
|
||||
@@ -190,4 +167,3 @@ export async function POST(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {NextResponse} from "next/server";
|
||||
import {isUuidv4} from "@/utils/verify-uuid";
|
||||
import {eventEmitter} from "../../../events/route";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {db} from "@/db";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
|
||||
import {eventEmitter} from "@/features/shared/event";
|
||||
|
||||
export type BodyResultRestore = {
|
||||
generatedId: string
|
||||
@@ -24,7 +24,6 @@ export async function POST(
|
||||
const agentId = (await params).agentId
|
||||
const body: BodyResultRestore = await request.json();
|
||||
|
||||
console.log(body)
|
||||
|
||||
if (!isUuidv4(body.generatedId)) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import {NextResponse} from "next/server";
|
||||
import {Body} from "./route";
|
||||
import {isUuidv4} from "@/utils/verify-uuid";
|
||||
import {getFileUrlPresignedLocal, getFileUrlPreSignedS3Action} from "@/features/upload/private/upload.action";
|
||||
import {Agent} from "@/db/schema/08_agent";
|
||||
import {Database} from "@/db/schema/07_database";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {db as dbClient} from "@/db";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
import {EDbmsSchema} from "@/db/schema/types";
|
||||
import {ServerActionResult} from "@/types/action-type";
|
||||
import {SafeActionResult} from "next-safe-action";
|
||||
import {ZodString} from "zod";
|
||||
import {and, eq, inArray} from "drizzle-orm";
|
||||
import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
import type {StorageInput} from "@/features/storages/types";
|
||||
import {dispatchStorage} from "@/features/storages/dispatch";
|
||||
|
||||
export async function handleDatabases(body: Body, agent: Agent, lastContact: Date) {
|
||||
const databasesResponse = [];
|
||||
@@ -48,6 +46,12 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
{status: 500}
|
||||
);
|
||||
}
|
||||
|
||||
if (!dbmsEnumSchema.safeParse(db.dbms).success) {
|
||||
console.log(`Database type not available: ${db.dbms}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const [databaseCreated] = await dbClient
|
||||
.insert(drizzleDb.schemas.database)
|
||||
.values({
|
||||
@@ -75,76 +79,67 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
.returning();
|
||||
|
||||
|
||||
const backup = await dbClient.query.backup.findFirst({
|
||||
where: and(eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.backup.status, "waiting"))
|
||||
const activeBackup = await dbClient.query.backup.findFirst({
|
||||
where: and(
|
||||
eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id),
|
||||
inArray(drizzleDb.schemas.backup.status, ["waiting", "ongoing"])
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
const restoration = await dbClient.query.restoration.findFirst({
|
||||
where: and(eq(drizzleDb.schemas.restoration.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.restoration.status, "waiting"))
|
||||
where: and(eq(drizzleDb.schemas.restoration.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.restoration.status, "waiting")),
|
||||
with: {
|
||||
backupStorage: true
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
if (backup) {
|
||||
if (activeBackup && activeBackup.status == "waiting") {
|
||||
backupAction = true
|
||||
|
||||
await dbClient
|
||||
.update(drizzleDb.schemas.backup)
|
||||
.set(withUpdatedAt({status: "ongoing"}))
|
||||
.where(eq(drizzleDb.schemas.backup.id, backup.id));
|
||||
.where(eq(drizzleDb.schemas.backup.id, activeBackup.id));
|
||||
}
|
||||
|
||||
if (restoration) {
|
||||
restoreAction = true
|
||||
|
||||
|
||||
const backupToRestore = await dbClient.query.backup.findFirst({
|
||||
where: eq(drizzleDb.schemas.backup.id, restoration.backupId),
|
||||
with: {
|
||||
database: {
|
||||
with: {
|
||||
project: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const [settings] = await dbClient.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
|
||||
if (!settings) {
|
||||
return NextResponse.json(
|
||||
{error: "Unable to find settings"},
|
||||
{status: 500}
|
||||
);
|
||||
if (!restoration.backupStorage || restoration.backupStorage.status != "success" || !restoration.backupStorage.path) {
|
||||
restoreAction = false
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
const fileName = backupToRestore?.file
|
||||
const input: StorageInput = {
|
||||
action: "get",
|
||||
data: {
|
||||
path: restoration.backupStorage.path,
|
||||
signedUrl: true,
|
||||
},
|
||||
metadata: {
|
||||
storageId: restoration.backupStorage.storageChannelId,
|
||||
fileKind: "backups"
|
||||
}
|
||||
};
|
||||
|
||||
let data: SafeActionResult<string, ZodString, readonly [], {
|
||||
_errors?: string[] | undefined;
|
||||
}, readonly [], ServerActionResult<string>, object> | undefined
|
||||
|
||||
try {
|
||||
const result = await dispatchStorage(input, undefined, restoration.backupStorage.storageChannelId);
|
||||
|
||||
if (settings.storage == "local") {
|
||||
data = await getFileUrlPresignedLocal({fileName: fileName!})
|
||||
} else if (settings.storage == "s3") {
|
||||
|
||||
data = await getFileUrlPreSignedS3Action(`backups/${backupToRestore?.database.project?.slug}/${fileName}`);
|
||||
}
|
||||
|
||||
if (data?.data?.success) {
|
||||
urlBackup = data.data.value ?? "";
|
||||
if (result.success) {
|
||||
urlBackup = result.url ?? "";
|
||||
} else {
|
||||
await dbClient
|
||||
.update(drizzleDb.schemas.restoration)
|
||||
.set({status: "failed"})
|
||||
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
||||
|
||||
// @ts-ignore
|
||||
const errorMessage = data?.data?.actionError?.message || "Failed to get presigned URL";
|
||||
const errorMessage = "Failed to get backup URL";
|
||||
console.error("Restoration failed: ", errorMessage);
|
||||
|
||||
continue;
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -153,9 +148,48 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
.update(drizzleDb.schemas.restoration)
|
||||
.set({status: "failed"})
|
||||
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// const fileName = backupToRestore?.file
|
||||
//
|
||||
// let data: SafeActionResult<string, ZodString, readonly [], {
|
||||
// _errors?: string[] | undefined;
|
||||
// }, readonly [], ServerActionResult<string>, object> | undefined
|
||||
//
|
||||
// try {
|
||||
//
|
||||
// if (settings.storage == "local") {
|
||||
// data = await getFileUrlPresignedLocal({fileName: fileName!})
|
||||
// } else if (settings.storage == "s3") {
|
||||
//
|
||||
// data = await getFileUrlPreSignedS3Action(`backups/${backupToRestore?.database.project?.slug}/${fileName}`);
|
||||
// }
|
||||
//
|
||||
// if (data?.data?.success) {
|
||||
// urlBackup = data.data.value ?? "";
|
||||
// } else {
|
||||
// await dbClient
|
||||
// .update(drizzleDb.schemas.restoration)
|
||||
// .set({status: "failed"})
|
||||
// .where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
||||
//
|
||||
// // @ts-ignore
|
||||
// const errorMessage = data?.data?.actionError?.message || "Failed to get presigned URL";
|
||||
// console.error("Restoration failed: ", errorMessage);
|
||||
//
|
||||
// continue;
|
||||
// }
|
||||
// } catch (err) {
|
||||
// console.error("Restoration crashed unexpectedly:", err);
|
||||
// await dbClient
|
||||
// .update(drizzleDb.schemas.restoration)
|
||||
// .set({status: "failed"})
|
||||
// .where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
||||
//
|
||||
// continue;
|
||||
// }
|
||||
await dbClient
|
||||
.update(drizzleDb.schemas.restoration)
|
||||
.set({status: "ongoing"})
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import {NextResponse} from "next/server";
|
||||
import {getFileUrlPresignedLocal} from "@/features/upload/private/upload.action";
|
||||
import {handleDatabases} from "./helpers";
|
||||
import {eventEmitter} from "../../../events/route";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {db} from "@/db";
|
||||
import {EDbmsSchema} from "@/db/schema/types";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {isUuidv4} from "@/utils/verify-uuid";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
import {eventEmitter} from "@/features/shared/event";
|
||||
|
||||
export type databaseAgent = {
|
||||
name: string,
|
||||
@@ -20,13 +19,6 @@ export type Body = {
|
||||
databases: databaseAgent[]
|
||||
}
|
||||
|
||||
// Function to test the get file url presigned local
|
||||
export async function GET(request: Request) {
|
||||
const url = await getFileUrlPresignedLocal({fileName: "d4a7fa35-2506-4d01-a612-a8ef2e2cc1c5.dump"})
|
||||
return Response.json({
|
||||
message: url
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
@@ -38,10 +30,8 @@ export async function POST(
|
||||
const lastContact = new Date();
|
||||
let message: string
|
||||
|
||||
|
||||
if (!isUuidv4(agentId)) {
|
||||
message = "agentId is not a valid uuid"
|
||||
console.log(message)
|
||||
return NextResponse.json(
|
||||
{error: "agentId is not a valid uuid"},
|
||||
{status: 500}
|
||||
@@ -54,12 +44,10 @@ export async function POST(
|
||||
|
||||
if (!agent) {
|
||||
message = "Agent not found"
|
||||
console.log(message)
|
||||
return NextResponse.json({error: message}, {status: 404})
|
||||
}
|
||||
const databasesResponse = await handleDatabases(body, agent, lastContact)
|
||||
|
||||
|
||||
await db
|
||||
.update(drizzleDb.schemas.agent)
|
||||
.set(withUpdatedAt({
|
||||
@@ -77,7 +65,6 @@ export async function POST(
|
||||
},
|
||||
databases: databasesResponse
|
||||
}
|
||||
console.log(response)
|
||||
|
||||
return Response.json(response)
|
||||
} catch (error) {
|
||||
|
||||
+3
-14
@@ -2,8 +2,9 @@ import {EventEmitter} from 'events';
|
||||
import {auth} from "@/lib/auth/auth";
|
||||
import {headers} from "next/headers";
|
||||
import {NextResponse} from "next/server";
|
||||
import {eventEmitter} from "@/features/shared/event";
|
||||
|
||||
export const eventEmitter = new EventEmitter();
|
||||
// export const eventEmitter = new EventEmitter();
|
||||
|
||||
export async function GET(request: Request) {
|
||||
|
||||
@@ -19,7 +20,6 @@ export async function GET(request: Request) {
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
console.log('Stream started');
|
||||
|
||||
const handleModification = (data: any) => {
|
||||
console.log('Modification event triggered:', data);
|
||||
controller.enqueue(`event: modification\n`);
|
||||
@@ -45,15 +45,4 @@ export async function GET(request: Request) {
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// export async function POST(request: Request) {
|
||||
// console.log('POST request received');
|
||||
// const data = await request.json();
|
||||
// console.log('Data received:', data);
|
||||
//
|
||||
// // Emit the event to all connected clients
|
||||
// eventEmitter.emit('modification', data);
|
||||
//
|
||||
// return new Response('Event sent', {status: 200});
|
||||
// }
|
||||
}
|
||||
@@ -1,29 +1,46 @@
|
||||
import * as fs from "node:fs";
|
||||
import {NextResponse} from "next/server";
|
||||
import path from "path";
|
||||
import type {StorageInput} from "@/features/storages/types";
|
||||
import {dispatchStorage} from "@/features/storages/dispatch";
|
||||
import {Readable} from "node:stream";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{params}: { params: Promise<{ fileName: string }> }
|
||||
) {
|
||||
|
||||
const {searchParams} = new URL(request.url);
|
||||
const token = searchParams.get('token');
|
||||
const expires = searchParams.get('expires');
|
||||
const fileName = (await params).fileName
|
||||
const pathFromUrl = searchParams.get('path');
|
||||
const storageId = searchParams.get('storageId');
|
||||
|
||||
const uploadsDir = "private/uploads/files/";
|
||||
const uploadPath = path.join(uploadsDir, fileName);
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
let filePath = null;
|
||||
if (fs.existsSync(uploadPath)) {
|
||||
filePath = uploadPath;
|
||||
} else {
|
||||
return NextResponse.json({error: "File not found"}, {status: 404})
|
||||
if (!pathFromUrl || !storageId) {
|
||||
return NextResponse.json({error: "Missing search params"}, {status: 404})
|
||||
}
|
||||
|
||||
const input: StorageInput = {
|
||||
action: "get",
|
||||
data: {
|
||||
path: pathFromUrl,
|
||||
signedUrl: true,
|
||||
},
|
||||
metadata: {
|
||||
storageId: storageId,
|
||||
fileKind: "backups",
|
||||
}
|
||||
};
|
||||
|
||||
console.debug(input);
|
||||
|
||||
const result = await dispatchStorage(input, undefined, storageId);
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json({error: "Enable to get file from provided storage channel, an error occurred !"})
|
||||
}
|
||||
|
||||
const fileName = path.basename(pathFromUrl);
|
||||
|
||||
const crypto = require('crypto');
|
||||
const expectedToken = crypto.createHash('sha256').update(`${fileName}${expires}`).digest('hex');
|
||||
if (token !== expectedToken) {
|
||||
return NextResponse.json(
|
||||
@@ -40,7 +57,15 @@ export async function GET(
|
||||
);
|
||||
}
|
||||
|
||||
const fileStream = fs.createReadStream(filePath);
|
||||
if (!result.file || !(result.file instanceof Readable)) {
|
||||
return NextResponse.json(
|
||||
{error: "Invalid file payload"},
|
||||
{status: 500}
|
||||
);
|
||||
}
|
||||
|
||||
const fileStream = Readable.from(result.file as Readable);
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
fileStream.on('data', (chunk) => controller.enqueue(chunk));
|
||||
@@ -0,0 +1,88 @@
|
||||
import {NextResponse} from "next/server";
|
||||
import {auth} from "@/lib/auth/auth";
|
||||
import {headers} from "next/headers";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {StorageInput} from "@/features/storages/types";
|
||||
import {dispatchStorage} from "@/features/storages/dispatch";
|
||||
import {Readable} from "node:stream";
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{params}: { params: Promise<{ fileName: string }> }
|
||||
) {
|
||||
const {searchParams} = new URL(req.url);
|
||||
const fileName = (await params).fileName;
|
||||
const storageId = searchParams.get('storageId');
|
||||
|
||||
if (!fileName) return NextResponse.json({error: "Missing file parameter"}, {status: 400});
|
||||
|
||||
const session = await auth.api.getSession({headers: await headers()});
|
||||
if (!session) return NextResponse.json({error: "Unauthorized"}, {status: 403});
|
||||
|
||||
|
||||
if (!storageId) {
|
||||
return NextResponse.json({error: "Missing storageId in search params"}, {status: 404})
|
||||
}
|
||||
|
||||
const ext = fileName.split(".").pop()?.toLowerCase();
|
||||
const contentType =
|
||||
ext === "png"
|
||||
? "image/png"
|
||||
: ext === "jpg" || ext === "jpeg"
|
||||
? "image/jpeg"
|
||||
: ext === "gif"
|
||||
? "image/gif"
|
||||
: ext === "webp"
|
||||
? "image/webp"
|
||||
: "application/octet-stream";
|
||||
|
||||
try {
|
||||
|
||||
const path = `images/${fileName}`;
|
||||
|
||||
const input: StorageInput = {
|
||||
action: "get",
|
||||
data: {
|
||||
path: path,
|
||||
},
|
||||
metadata: {
|
||||
storageId: storageId,
|
||||
fileKind: "images"
|
||||
}
|
||||
}
|
||||
|
||||
const result = await dispatchStorage(input, undefined, storageId);
|
||||
|
||||
if (!result.file || !(result.file instanceof Readable)) {
|
||||
console.error(`An error occurred while getting file :`, result);
|
||||
return NextResponse.json(
|
||||
{error: "Invalid file payload"},
|
||||
{status: 500}
|
||||
);
|
||||
}
|
||||
|
||||
const fileStream = Readable.from(result.file as Readable);
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
fileStream.on('data', (chunk) => controller.enqueue(chunk));
|
||||
fileStream.on('end', () => controller.close());
|
||||
fileStream.on('error', (err) => controller.error(err));
|
||||
},
|
||||
});
|
||||
|
||||
return new NextResponse(stream, {
|
||||
headers: {
|
||||
'Content-Disposition': `inline; filename="${fileName}"`,
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": contentType,
|
||||
},
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error("Error streaming image:", err);
|
||||
return NextResponse.json({error: "Error fetching file"}, {status: 500});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
const code = url.searchParams.get("code");
|
||||
|
||||
const success = Boolean(code);
|
||||
|
||||
return new Response(
|
||||
`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>${success ? "Success" : "Failed"}</h1>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
{
|
||||
status: success ? 200 : 400,
|
||||
headers: {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import {NextResponse} from "next/server";
|
||||
import {auth} from "@/lib/auth/auth";
|
||||
import {headers} from "next/headers";
|
||||
import {checkFileExistsInBucket, getObjectFromClient} from "@/utils/s3-file-management";
|
||||
import {env} from "@/env.mjs";
|
||||
import * as stream from "node:stream";
|
||||
import path from "path";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import fs from "fs/promises";
|
||||
|
||||
function nodeStreamToWebStream(nodeStream: stream.Readable) {
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
nodeStream.on("data", chunk => controller.enqueue(chunk));
|
||||
nodeStream.on("end", () => controller.close());
|
||||
nodeStream.on("error", err => controller.error(err));
|
||||
},
|
||||
cancel() {
|
||||
nodeStream.destroy();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const privateS3ImageDir = "images/";
|
||||
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{params}: { params: Promise<{ fileName: string }> }
|
||||
) {
|
||||
const fileName = (await params).fileName;
|
||||
if (!fileName) return NextResponse.json({error: "Missing file parameter"}, {status: 400});
|
||||
|
||||
const session = await auth.api.getSession({headers: await headers()});
|
||||
if (!session) return NextResponse.json({error: "Unauthorized"}, {status: 403});
|
||||
|
||||
const [settings] = await db
|
||||
.select()
|
||||
.from(drizzleDb.schemas.setting)
|
||||
.where(eq(drizzleDb.schemas.setting.name, "system"))
|
||||
.limit(1);
|
||||
|
||||
if (!settings) throw new Error("System settings not found.");
|
||||
|
||||
const storageType = settings.storage; // "local" or "s3"
|
||||
const ext = fileName.split(".").pop()?.toLowerCase();
|
||||
const contentType =
|
||||
ext === "png"
|
||||
? "image/png"
|
||||
: ext === "jpg" || ext === "jpeg"
|
||||
? "image/jpeg"
|
||||
: ext === "gif"
|
||||
? "image/gif"
|
||||
: ext === "webp"
|
||||
? "image/webp"
|
||||
: "application/octet-stream";
|
||||
|
||||
try {
|
||||
if (storageType === "local") {
|
||||
const filePath = path.join(process.cwd(), "private/uploads/images", fileName);
|
||||
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
const file = await fs.readFile(filePath);
|
||||
|
||||
return new NextResponse(file, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Disposition": `inline; filename="${fileName}"`,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// if not found locally, fallback to S3
|
||||
}
|
||||
}
|
||||
|
||||
const exists = await checkFileExistsInBucket({
|
||||
bucketName: env.S3_BUCKET_NAME!,
|
||||
fileName: `${privateS3ImageDir}${fileName}`,
|
||||
});
|
||||
if (!exists) return NextResponse.json({error: "File not found"}, {status: 404});
|
||||
|
||||
const nodeStream = await getObjectFromClient({
|
||||
bucketName: env.S3_BUCKET_NAME!,
|
||||
fileName: `${privateS3ImageDir}${fileName}`,
|
||||
});
|
||||
const webStream = nodeStreamToWebStream(nodeStream);
|
||||
|
||||
return new NextResponse(webStream, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Disposition": `inline; filename="${fileName}"`,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error streaming image:", err);
|
||||
return NextResponse.json({error: "Error fetching file"}, {status: 500});
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -6,6 +6,7 @@ import {ThemeProvider} from "@/features/theme/theme-provider";
|
||||
import {Toaster} from "@/components/ui/sonner";
|
||||
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
|
||||
import {ThemeMetaUpdaterRoot} from "@/features/browser/theme-meta-updater-root";
|
||||
import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
|
||||
|
||||
|
||||
export type ProviderProps = PropsWithChildren<{}>;
|
||||
@@ -21,8 +22,8 @@ export const Providers = (props: ProviderProps) => {
|
||||
>
|
||||
<ThemeMetaUpdaterRoot/>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Toaster/>
|
||||
{props.children}
|
||||
<Toaster/>
|
||||
{props.children}
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</Suspense>
|
||||
|
||||
@@ -3,11 +3,11 @@ name: portabase-prod
|
||||
services:
|
||||
|
||||
app:
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: docker/dockerfile/Dockerfile
|
||||
# target: prod
|
||||
image: solucetechnologies/portabase:1.1.4-rc.2
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/dockerfile/Dockerfile
|
||||
target: prod
|
||||
# image: solucetechnologies/portabase:latest
|
||||
ports:
|
||||
- '8887:80'
|
||||
environment:
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ name: portabase-dev
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
image: postgres:17-alpine
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
|
||||
@@ -1,45 +1,37 @@
|
||||
FROM node:22-alpine AS base
|
||||
|
||||
ENV YARN_VERSION=4.9.1
|
||||
|
||||
RUN apk add --update --no-cache \
|
||||
tzdata \
|
||||
build-base \
|
||||
libc6-compat \
|
||||
openssl \
|
||||
make \
|
||||
tzdata
|
||||
|
||||
FROM base AS build-env
|
||||
|
||||
RUN apk add --update --no-cache \
|
||||
build-base \
|
||||
g++ \
|
||||
jpeg-dev \
|
||||
cairo-dev \
|
||||
giflib-dev \
|
||||
pango-dev \
|
||||
make \
|
||||
libtool \
|
||||
autoconf \
|
||||
automake \
|
||||
libpng
|
||||
automake
|
||||
|
||||
RUN corepack enable && corepack prepare yarn@${YARN_VERSION}
|
||||
RUN yarn set version ${YARN_VERSION}
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
|
||||
FROM base AS deps
|
||||
|
||||
|
||||
FROM build-env AS deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY .yarn ./.yarn
|
||||
COPY .yarnrc.yml ./
|
||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
|
||||
RUN pnpm i --frozen-lockfile
|
||||
|
||||
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn; \
|
||||
elif [ -f package-lock.json ]; then npm ci; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
|
||||
FROM base AS dev
|
||||
FROM build-env AS dev
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -52,8 +44,11 @@ RUN chmod +x /app/docker/entrypoints/app-dev-entrypoint.sh
|
||||
ENTRYPOINT ["sh","/app/docker/entrypoints/app-dev-entrypoint.sh"]
|
||||
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
|
||||
|
||||
|
||||
|
||||
FROM build-env AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
@@ -61,12 +56,10 @@ COPY . .
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn run build; \
|
||||
elif [ -f package-lock.json ]; then npm run build; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
RUN pnpm run build
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
FROM base AS prod
|
||||
@@ -76,6 +69,9 @@ WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
@@ -87,14 +83,18 @@ RUN mkdir -p /app/private/uploads
|
||||
RUN chown -R nextjs:nodejs /app/private
|
||||
RUN chown -R nextjs:nodejs /app/public
|
||||
|
||||
|
||||
COPY --from=builder /app/next.config.ts ./
|
||||
COPY --from=builder /app/server ./server
|
||||
COPY --from=builder /app/src ./src
|
||||
|
||||
COPY --from=builder /app/portabase.config.ts ./
|
||||
COPY --from=builder /app/drizzle.config.ts ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --chown=nextjs:nodejs src/db ./src/db
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
|
||||
USER root
|
||||
|
||||
COPY ./docker/entrypoints/app-prod-entrypoint.sh /app/app-prod-entrypoint.sh
|
||||
@@ -109,5 +109,3 @@ ENV HOSTNAME="0.0.0.0"
|
||||
USER nextjs
|
||||
|
||||
ENTRYPOINT ["sh","/app/app-prod-entrypoint.sh"]
|
||||
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
set -euo pipefail
|
||||
|
||||
echo "▶ Running Drizzle codegen..."
|
||||
npx drizzle-kit generate
|
||||
pnpm drizzle-kit generate
|
||||
|
||||
echo "▶ Applying migrations..."
|
||||
npx drizzle-kit migrate
|
||||
pnpm drizzle-kit migrate
|
||||
|
||||
echo "▶ Starting Next.js dev server..."
|
||||
exec npm run dev
|
||||
exec pnpm dev
|
||||
@@ -7,8 +7,8 @@ else
|
||||
echo "[WARN] No TZ provided, using default container timezone"
|
||||
fi
|
||||
|
||||
|
||||
node server.js
|
||||
pnpm run start
|
||||
#node server.js
|
||||
|
||||
exec "$@"
|
||||
|
||||
|
||||
+2
-1
@@ -43,8 +43,9 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: "50mb",
|
||||
bodySizeLimit: "10gb",
|
||||
},
|
||||
proxyClientMaxBodySize: '10gb',
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
|
||||
+16
-6
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "portabase",
|
||||
"version": "1.1.6",
|
||||
"version": "1.2.5-rc.7",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 8887",
|
||||
"dev": "NODE_ENV=development tsx watch server/server.ts",
|
||||
"build": "next build --experimental-build-mode compile",
|
||||
"start": "next start",
|
||||
"start": "NODE_ENV=production tsx server/server.ts",
|
||||
|
||||
"lint": "next lint",
|
||||
"email": "email dev --dir ./src/components/emails",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
@@ -47,6 +48,7 @@
|
||||
"@t3-oss/env-nextjs": "^0.13.4",
|
||||
"@tanstack/react-query": "^5.76.1",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@zenstackhq/runtime": "2.14.2",
|
||||
@@ -62,11 +64,13 @@
|
||||
"drizzle-orm": "^0.43.1",
|
||||
"drizzle-zod": "^0.7.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"express": "^5.2.1",
|
||||
"googleapis": "^170.1.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.553.0",
|
||||
"minio": "^8.0.5",
|
||||
"motion": "^12.23.24",
|
||||
"next": "16.0.10",
|
||||
"next": "16.1.5",
|
||||
"next-safe-action": "^7.10.8",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-cron": "^4.2.1",
|
||||
@@ -74,6 +78,7 @@
|
||||
"nodemailer": "^7.0.3",
|
||||
"npm-check-updates": "^18.0.1",
|
||||
"pg": "^8.16.0",
|
||||
"prettier": "^3.8.0",
|
||||
"react": "^19.2.0",
|
||||
"react-day-picker": "9.7.0",
|
||||
"react-dom": "^19.2.0",
|
||||
@@ -85,9 +90,11 @@
|
||||
"react-twc": "^1.4.2",
|
||||
"react-use-measure": "^2.1.7",
|
||||
"recharts": "^2.15.3",
|
||||
"sharp": "^0.34.5",
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"sonner": "^2.0.3",
|
||||
"swiper": "^12.0.3",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"uuid": "^11.1.0",
|
||||
"vaul": "^1.1.2",
|
||||
@@ -97,6 +104,7 @@
|
||||
"devDependencies": {
|
||||
"@iconify/react": "^6.0.0",
|
||||
"@react-email/preview-server": "4.3.2",
|
||||
"@react-email/render": "^2.0.1",
|
||||
"@tailwindcss/postcss": "^4.1.7",
|
||||
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
||||
"@types/node": "^22.15.18",
|
||||
@@ -106,11 +114,13 @@
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@zenstackhq/openapi": "^2.14.2",
|
||||
"@zenstackhq/tanstack-query": "^2.14.2",
|
||||
"baseline-browser-mapping": "^2.8.32",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"drizzle-kit": "^0.31.1",
|
||||
"esbuild": "^0.27.2",
|
||||
"eslint": "^9.39.0",
|
||||
"eslint-config-next": "^16.0.1",
|
||||
"eslint-plugin-tailwindcss": "^3.18.0",
|
||||
"framer-motion": "^12.24.7",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"tsx": "^4.19.4",
|
||||
@@ -118,5 +128,5 @@
|
||||
"typescript": "^5.8.3",
|
||||
"zenstack": "2.14.2"
|
||||
},
|
||||
"packageManager": "yarn@4.9.1"
|
||||
"packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316"
|
||||
}
|
||||
|
||||
Generated
+13869
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
onlyBuiltDependencies:
|
||||
- '@prisma/client'
|
||||
- '@prisma/engines'
|
||||
- argon2
|
||||
- bcrypt
|
||||
- cpu-features
|
||||
- esbuild
|
||||
- prisma
|
||||
- protobufjs
|
||||
- sharp
|
||||
- ssh2
|
||||
- unrs-resolver
|
||||
- zenstack
|
||||
@@ -54,11 +54,12 @@ function checkRouteExists(pathname: string) {
|
||||
/^\/api\/agent\/[^/]+\/status\/?$/,
|
||||
/^\/api\/agent\/[^/]+\/backup\/?$/,
|
||||
/^\/api\/agent\/[^/]+\/restore\/?$/,
|
||||
/^\/api\/files\/[^/]+\/?$/,
|
||||
/^\/api\/images\/[^/]+\/?$/,
|
||||
/^\/api\/files\/images\/[^/]+\/?$/,
|
||||
/^\/api\/files\/backups\/?$/,
|
||||
/^\/api\/events\/?$/,
|
||||
/^\/api\/init\/?$/,
|
||||
/^\/api\/config\/?$/,
|
||||
/^\/api\/google\/drive\/callback\/?$/,
|
||||
];
|
||||
return routePatterns.some((pattern) => pattern.test(pathname));
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,95 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
if [ -z "$1" ]; then
|
||||
echo "Usage: ./release <version>"
|
||||
echo "Example: ./release v1.0.0"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION=$1
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
|
||||
if [ "$CURRENT_BRANCH" = "main" ]; then
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Error: On 'main' branch, only release tags (X.Y.Z) are allowed."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
|
||||
echo "Error: On branch '$CURRENT_BRANCH', only RC tags matching X.Y.Z-rc.W are allowed (e.g., 1.0.0-rc.1)."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
CLEAN_VERSION=${VERSION#v}
|
||||
CURRENT_DATE=$(date +%Y-%m-%d)
|
||||
|
||||
echo "Preparing release $VERSION..."
|
||||
|
||||
|
||||
# package.json
|
||||
if [ -f package.json ]; then
|
||||
echo "Updating package.json..."
|
||||
if sed --version >/dev/null 2>&1; then
|
||||
sed -i "s/\"version\": \".*\"/\"version\": \"$CLEAN_VERSION\"/" package.json
|
||||
else
|
||||
sed -i '' "s/\"version\": \".*\"/\"version\": \"$CLEAN_VERSION\"/" package.json
|
||||
fi
|
||||
fi
|
||||
|
||||
# pyproject.toml
|
||||
if [ -f pyproject.toml ]; then
|
||||
echo "Updating pyproject.toml..."
|
||||
if sed --version >/dev/null 2>&1; then
|
||||
sed -i "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" pyproject.toml
|
||||
else
|
||||
sed -i '' "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" pyproject.toml
|
||||
fi
|
||||
fi
|
||||
|
||||
# Cargo.toml
|
||||
if [ -f Cargo.toml ]; then
|
||||
echo "Updating Cargo.toml..."
|
||||
if sed --version >/dev/null 2>&1; then
|
||||
sed -i "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" Cargo.toml
|
||||
else
|
||||
sed -i '' "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" Cargo.toml
|
||||
fi
|
||||
fi
|
||||
|
||||
# CITATION.cff
|
||||
if [ -f CITATION.cff ]; then
|
||||
echo "Updating CITATION.cff..."
|
||||
if sed --version >/dev/null 2>&1; then
|
||||
sed -i "s/^version: .*/version: $CLEAN_VERSION/" CITATION.cff
|
||||
sed -i "s/^date-released: .*/date-released: \"$CURRENT_DATE\"/" CITATION.cff
|
||||
else
|
||||
sed -i '' "s/^version: .*/version: $CLEAN_VERSION/" CITATION.cff
|
||||
sed -i '' "s/^date-released: .*/date-released: \"$CURRENT_DATE\"/" CITATION.cff
|
||||
fi
|
||||
fi
|
||||
|
||||
git add .
|
||||
|
||||
if ! git diff-index --quiet HEAD --; then
|
||||
echo "Committing changes..."
|
||||
git commit -m "chore(release): $VERSION"
|
||||
else
|
||||
echo "No changes to commit. Proceeding to tag..."
|
||||
fi
|
||||
|
||||
if git rev-parse "$VERSION" >/dev/null 2>&1; then
|
||||
echo "Tag $VERSION already exists. Aborting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Creating tag $VERSION..."
|
||||
git tag -a "$VERSION" -m "Release $VERSION"
|
||||
|
||||
echo "Pushing changes and tags to remote..."
|
||||
git push
|
||||
git push origin "$VERSION"
|
||||
|
||||
echo "Successfully released $VERSION!"
|
||||
@@ -0,0 +1,6 @@
|
||||
import express from "express";
|
||||
import router from "../src/features/api/router";
|
||||
|
||||
export function mountApi(app: express.Express) {
|
||||
app.use("/services/v1", router);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import next from "next";
|
||||
import express from "express";
|
||||
import {mountApi} from "./api";
|
||||
import http from "http";
|
||||
|
||||
const port = Number(process.env.PORT) || 8887;
|
||||
const dev = process.env.NODE_ENV !== "production";
|
||||
|
||||
async function start() {
|
||||
const nextApp = next({
|
||||
dev,
|
||||
hostname: "0.0.0.0",
|
||||
turbopack: dev,
|
||||
port
|
||||
});
|
||||
|
||||
const handle = nextApp.getRequestHandler();
|
||||
|
||||
await nextApp.prepare();
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use((req, res, next) => {
|
||||
req.setTimeout(0);
|
||||
res.setTimeout(0);
|
||||
next();
|
||||
});
|
||||
|
||||
mountApi(app);
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (req.path.startsWith('/services/v1')) return next();
|
||||
handle(req, res).catch((err) => {
|
||||
console.error('Next.js App Router error:', err);
|
||||
res.status(500).send('Internal Server Error');
|
||||
});
|
||||
});
|
||||
|
||||
const server = http.createServer(app);
|
||||
|
||||
server.setTimeout(0);
|
||||
server.headersTimeout = 0;
|
||||
server.requestTimeout = 0;
|
||||
server.keepAliveTimeout = 0;
|
||||
|
||||
server.listen(port, "0.0.0.0", () => {
|
||||
console.log(`NEXT APP → http://localhost:${port}`);
|
||||
console.log(`API → http://localhost:${port}/services/v1`);
|
||||
});
|
||||
}
|
||||
|
||||
start();
|
||||
@@ -46,7 +46,9 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: R
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
// @ts-ignore
|
||||
IconLeft: ({ className, children: _children, ...props }) => <ChevronLeftIcon className={cn("size-4", className)} {...props} />,
|
||||
// @ts-ignore
|
||||
IconRight: ({ className, children: _children, ...props }) => <ChevronRightIcon className={cn("size-4", className)} {...props} />,
|
||||
}}
|
||||
{...props}
|
||||
|
||||
@@ -58,7 +58,7 @@ function DialogContent({
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
"max-h-[90vh] overflow-y-auto",
|
||||
"max-h-[90vh] overflow-y-auto overflow-x-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -41,7 +41,7 @@ export const AuthLogoSection = () => {
|
||||
|
||||
/>
|
||||
)}
|
||||
<span className="absolute bottom-10 right-5 text-sm text-muted-foreground" style={style}>
|
||||
<span className="absolute bottom-8 right-5 text-sm text-muted-foreground" style={style}>
|
||||
v{env.NEXT_PUBLIC_PROJECT_VERSION}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import {useEffect, useState} from "react";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {toast} from "sonner";
|
||||
|
||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Form} from "@/components/ui/form";
|
||||
@@ -63,7 +62,6 @@ export const LoginForm = (props: loginFormProps) => {
|
||||
toast.success("Login success");
|
||||
},
|
||||
onError: (error) => {
|
||||
console.log(error);
|
||||
toast.error(error.error.message);
|
||||
},
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ export const resetPasswordAction = action
|
||||
}
|
||||
|
||||
const user = await (await auth.$context).internalAdapter.findUserById(verification.value);
|
||||
console.log(user)
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -39,28 +38,6 @@ export const resetPasswordAction = action
|
||||
};
|
||||
}
|
||||
|
||||
const hashedPassword = await (await auth.$context).password.hash(parsedInput.schema.password);
|
||||
console.log(hashedPassword)
|
||||
console.log("ok")
|
||||
// await (await auth.$context).internalAdapter.updatePassword(user.id, hashedPassword);
|
||||
console.log("ici")
|
||||
// await (await auth.$context).internalAdapter.deleteSessions(user.id);
|
||||
// console.log("ici2")
|
||||
// await (await auth.$context).internalAdapter.deleteVerificationValue(verification.id);
|
||||
// console.log("ici3");
|
||||
//
|
||||
// (await auth.$context).internalAdapter.updateUser(user.id, {
|
||||
// lastChangedPasswordAt: new Date(),
|
||||
// });
|
||||
|
||||
// await auth.api.resetPassword({
|
||||
// headers: await headers(),
|
||||
// body: {
|
||||
// newPassword: parsedInput.schema.password,
|
||||
// token: parsedInput.token,
|
||||
// },
|
||||
// });
|
||||
|
||||
await (
|
||||
await auth.$context
|
||||
).internalAdapter.updateUser(user.id, {
|
||||
|
||||
@@ -44,7 +44,6 @@ export const ResetPasswordForm = (props: ResetPasswordFormProps) => {
|
||||
setTimeout(() => router.push("/"), 1400);
|
||||
},
|
||||
onError: (error: BetterAuthError) => {
|
||||
console.log(error)
|
||||
toast.error("An error occurred while resetting password");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import {useRouter} from "next/navigation";
|
||||
import {Info} from "lucide-react";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {toast} from "sonner";
|
||||
|
||||
import {Card, CardContent, CardHeader} from "@/components/ui/card";
|
||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {CardContent, CardHeader} from "@/components/ui/card";
|
||||
import {Form} from "@/components/ui/form";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {TooltipProvider, TooltipTrigger, Tooltip, TooltipContent} from "@/components/ui/tooltip";
|
||||
import {RegisterSchema, RegisterType} from "@/components/wrappers/auth/register/register-form/register-form.schema";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
import {signUp} from "@/lib/auth/auth-client";
|
||||
import Link from "next/link";
|
||||
import {useZodForm} from "@/components/ui/form";
|
||||
import {CardAuth} from "@/features/layout/card-auth";
|
||||
import {FormField} from "@/components/ui/form";
|
||||
import {FormItem} from "@/components/ui/form";
|
||||
import {FormLabel} from "@/components/ui/form";
|
||||
import {FormControl} from "@/components/ui/form";
|
||||
import {FormMessage} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import {Info} from "lucide-react";
|
||||
|
||||
export type registerFormProps = {
|
||||
defaultValues?: RegisterType;
|
||||
@@ -26,9 +30,11 @@ export const RegisterForm = (props: registerFormProps) => {
|
||||
const form = useZodForm({
|
||||
schema: RegisterSchema,
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: RegisterType) => {
|
||||
// @ts-ignore
|
||||
await signUp.email(values, {
|
||||
onSuccess: () => {
|
||||
toast.success(`Account successfully created`);
|
||||
@@ -36,7 +42,6 @@ export const RegisterForm = (props: registerFormProps) => {
|
||||
router.push(`/login`);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.log(error);
|
||||
toast.error(error.error.message);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import {Form, FormControl, FormField, FormItem, FormLabel, useZodForm} from "@/c
|
||||
import {PasswordStrengthInput} from "@/components/ui/password-input-indicator";
|
||||
import {ResetPasswordSchema, ResetPasswordType} from "@/components/wrappers/auth/reset-password/reset-password-schema";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
import {Card, CardContent, CardHeader} from "@/components/ui/card";
|
||||
import {CardContent, CardHeader} from "@/components/ui/card";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {authClient} from "@/lib/auth/auth-client";
|
||||
import {toast} from "sonner";
|
||||
@@ -31,7 +31,6 @@ export const ResetPasswordForm = ({token}: ResetPasswordFormProps) => {
|
||||
token,
|
||||
}, {
|
||||
onSuccess: (response) => {
|
||||
console.log(response);
|
||||
toast.success("Password changed successfully.");
|
||||
setTimeout(() => router.push("/"), 1000);
|
||||
},
|
||||
|
||||
@@ -1,48 +1,3 @@
|
||||
// "use client";
|
||||
//
|
||||
// import { Button } from "@/components/ui/button";
|
||||
// import { useState } from "react";
|
||||
// import { Loader2 } from "lucide-react";
|
||||
//
|
||||
// export type VariantButton = {
|
||||
// secondary: string;
|
||||
// default: string;
|
||||
// outline: string;
|
||||
// ghost: string;
|
||||
// link: string;
|
||||
// destructive: string;
|
||||
// };
|
||||
//
|
||||
// export type ButtonWithConfirmProps = {
|
||||
// icon?: any;
|
||||
// text: string;
|
||||
// variant?: keyof VariantButton;
|
||||
// className?: string;
|
||||
// onClick?: () => void;
|
||||
// isPending?: boolean;
|
||||
// };
|
||||
//
|
||||
// export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
|
||||
// const [isConfirming, setIsConfirming] = useState(false);
|
||||
//
|
||||
// return (
|
||||
// <Button
|
||||
// onClick={() => {
|
||||
// if (isConfirming && props.onClick) {
|
||||
// props.onClick();
|
||||
// } else {
|
||||
// setIsConfirming(true);
|
||||
// }
|
||||
// }}
|
||||
// variant={props.variant ? props.variant : "default"}
|
||||
// className={props.className}
|
||||
// >
|
||||
// {props.isPending && <Loader2 className="animate-spin mr-4" size={16} />}
|
||||
// {isConfirming ? "Are you sure ?" : `${props.text}`}
|
||||
// <>{props.icon ? props.icon : null}</>
|
||||
// </Button>
|
||||
// );
|
||||
// };
|
||||
"use client"
|
||||
import {Button, ButtonVariantsProps} from "@/components/ui/button";
|
||||
import {useState} from "react";
|
||||
@@ -57,6 +12,7 @@ export type ButtonWithConfirmProps = {
|
||||
button: {
|
||||
main: {
|
||||
className?: string;
|
||||
type?: "button" | "submit" | "reset" | undefined;
|
||||
text?: string;
|
||||
icon?: any;
|
||||
variant?: ButtonVariantsProps["variant"];
|
||||
@@ -100,6 +56,7 @@ export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
|
||||
)}
|
||||
role="button">
|
||||
<Button
|
||||
type={props.button.main.type}
|
||||
disabled={!!props.button.main.disabled}
|
||||
variant={props.button.main.variant ?? "default"}
|
||||
size={props.button.main.size ?? "default"}
|
||||
@@ -108,7 +65,8 @@ export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
|
||||
if (!props.button.main.disabled) setIsConfirming(true);
|
||||
}}
|
||||
>
|
||||
{props.button.main.icon}
|
||||
{props.isPending && <Loader2 className="animate-spin mr-4" size={16}/>}
|
||||
{props.button.main.icon}
|
||||
{props.button.main.text && <span>{props.button.main.text}</span>}
|
||||
</Button>
|
||||
</span>
|
||||
|
||||
@@ -109,7 +109,6 @@ export const ButtonWithLoading = ({
|
||||
{isPending && <Loader2 className="mr-2 animate-spin" size={16} />}
|
||||
{children && children}
|
||||
<>{icon ? icon : null}</>
|
||||
{/*{icon && <span className="ml-2">{icon}</span>}*/}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import {cn} from "@/lib/utils";
|
||||
|
||||
export type ConnectionCircleProps = {
|
||||
date?: Date | null;
|
||||
};
|
||||
|
||||
export const ConnectionCircle = ({date}: ConnectionCircleProps) => {
|
||||
let style = "bg-gray-300 border-gray-400";
|
||||
|
||||
if (date instanceof Date && !isNaN(date.getTime())) {
|
||||
const now = Date.now();
|
||||
const timestamp = date.getTime();
|
||||
const interval_seconds = (now - timestamp) / 1000;
|
||||
|
||||
console.log({now, timestamp, interval_seconds});
|
||||
|
||||
if (interval_seconds < 55) {
|
||||
style = "bg-green-400 border-green-600";
|
||||
} else if (interval_seconds <= 60) {
|
||||
style = "bg-orange-400 border-orange-600";
|
||||
} else {
|
||||
style = "bg-red-400 border-red-600";
|
||||
}
|
||||
} else {
|
||||
console.warn("Invalid date passed to ConnectionCircle:", date);
|
||||
}
|
||||
|
||||
console.log(style);
|
||||
|
||||
return <div className={cn("w-5 h-5 rounded-full border-4", style)}/>;
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import {cn} from "@/lib/utils";
|
||||
|
||||
export type ConnectionIndicatorProps = {
|
||||
date?: Date | null;
|
||||
};
|
||||
|
||||
export const ConnectionIndicator = ({date}: ConnectionIndicatorProps) => {
|
||||
let style = "bg-gray-300";
|
||||
|
||||
if (date instanceof Date && !isNaN(date.getTime())) {
|
||||
const intervalSeconds = (Date.now() - date.getTime()) / 1000;
|
||||
|
||||
if (intervalSeconds < 55) {
|
||||
style = "bg-green-500";
|
||||
} else if (intervalSeconds <= 60) {
|
||||
style = "bg-orange-400";
|
||||
} else {
|
||||
style = "bg-red-500";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-3 h-3">
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -inset-0.25 rounded-full opacity-60 animate-ping",
|
||||
style
|
||||
)}
|
||||
style={{
|
||||
animationDuration: "2s",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-3 h-3 rounded-full shadow-sm animate-pulse",
|
||||
style
|
||||
)}
|
||||
style={{
|
||||
animationDuration: "2s",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,20 +1,22 @@
|
||||
import Link from "next/link";
|
||||
import {cn} from "@/lib/utils";
|
||||
import {Plus} from "lucide-react";
|
||||
import {forwardRef, HTMLAttributes} from "react";
|
||||
|
||||
type EmptyStatePlaceholderProps = {
|
||||
url?: string;
|
||||
onClick?: () => void;
|
||||
text: string;
|
||||
className?: string;
|
||||
};
|
||||
} & HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const EmptyStatePlaceholder = ({
|
||||
export const EmptyStatePlaceholder = forwardRef<HTMLDivElement, EmptyStatePlaceholderProps>(({
|
||||
url,
|
||||
onClick,
|
||||
text,
|
||||
className,
|
||||
}: EmptyStatePlaceholderProps) => {
|
||||
...props
|
||||
}, ref) => {
|
||||
const Container = (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -37,5 +39,7 @@ export const EmptyStatePlaceholder = ({
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={cn(className)}>{Container}</div>;
|
||||
};
|
||||
return <div className={cn(className)} ref={ref} {...props}>{Container}</div>;
|
||||
});
|
||||
|
||||
EmptyStatePlaceholder.displayName = "EmptyStatePlaceholder";
|
||||
@@ -15,7 +15,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command
|
||||
* Variants for the multi-select component to handle different styles.
|
||||
* Uses class-variance-authority (cva) to define different styles based on "variant" prop.
|
||||
*/
|
||||
const multiSelectVariants = cva("m-1 transition ease-in-out delay-150 hover:-translate-y-1 hover:scale-110 duration-300", {
|
||||
const multiSelectVariants = cva("m-1 transition ease-in-out duration-300", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-foreground/10 text-foreground bg-card hover:bg-card/80",
|
||||
@@ -177,13 +177,13 @@ export const MultiSelect = React.forwardRef<HTMLButtonElement, MultiSelectProps>
|
||||
return (
|
||||
<Badge
|
||||
key={value}
|
||||
className={cn(isAnimating ? "animate-bounce" : "", multiSelectVariants({ variant }))}
|
||||
className={cn(isAnimating ? "animate-bounce" : "", "max-w-full", multiSelectVariants({ variant }))}
|
||||
style={{ animationDuration: `${animation}s` }}
|
||||
>
|
||||
{IconComponent && <IconComponent className="h-4 w-4 mr-2" />}
|
||||
{option?.label}
|
||||
<span className="truncate">{option?.label}</span>
|
||||
<XCircle
|
||||
className="ml-2 h-4 w-4 cursor-pointer"
|
||||
className="ml-2 h-4 w-4 cursor-pointer shrink-0"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
toggleOption(value);
|
||||
@@ -196,6 +196,7 @@ export const MultiSelect = React.forwardRef<HTMLButtonElement, MultiSelectProps>
|
||||
<Badge
|
||||
className={cn(
|
||||
"bg-transparent text-foreground border-foreground/1 hover:bg-transparent",
|
||||
"max-w-full",
|
||||
isAnimating ? "animate-bounce" : "",
|
||||
multiSelectVariants({ variant })
|
||||
)}
|
||||
@@ -203,7 +204,7 @@ export const MultiSelect = React.forwardRef<HTMLButtonElement, MultiSelectProps>
|
||||
>
|
||||
{`+ ${selectedValues.length - maxCount} more`}
|
||||
<XCircle
|
||||
className="ml-2 h-4 w-4 cursor-pointer"
|
||||
className="ml-2 h-4 w-4 cursor-pointer shrink-0"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
clearExtraOptions();
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"use client"
|
||||
|
||||
import {Pencil, Plus} from "lucide-react";
|
||||
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger
|
||||
} from "@/components/ui/dialog";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
||||
import {useIsMobile} from "@/hooks/use-mobile";
|
||||
import {useEffect, useState} from "react";
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
|
||||
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
||||
import {ChannelForm} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form";
|
||||
import {ChannelKind, getChannelTextBasedOnKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
|
||||
import {
|
||||
ChannelOrganisationForm
|
||||
} from "@/components/wrappers/dashboard/admin/channels/organization/channels-organization-form";
|
||||
|
||||
type ChannelAddModalProps = {
|
||||
channel?: NotificationChannelWith | StorageChannelWith
|
||||
organization?: OrganizationWithMembers;
|
||||
open?: boolean;
|
||||
onOpenChangeAction?: (open: boolean) => void;
|
||||
adminView?: boolean;
|
||||
organizations?: OrganizationWithMembers[]
|
||||
trigger?: boolean;
|
||||
kind: ChannelKind;
|
||||
}
|
||||
|
||||
|
||||
export const ChannelAddEditModal = ({
|
||||
organization,
|
||||
channel,
|
||||
open = false,
|
||||
onOpenChangeAction,
|
||||
adminView,
|
||||
organizations,
|
||||
trigger = true,
|
||||
kind
|
||||
}: ChannelAddModalProps) => {
|
||||
const isMobile = useIsMobile();
|
||||
const [openInternal, setOpen] = useState(open);
|
||||
const isLocalSystem = channel?.provider == "local";
|
||||
|
||||
const isCreate = !Boolean(channel);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(open);
|
||||
}, [open])
|
||||
|
||||
|
||||
const channelText = getChannelTextBasedOnKind(kind)
|
||||
|
||||
|
||||
return (
|
||||
<Dialog open={openInternal} onOpenChange={(state) => {
|
||||
onOpenChangeAction?.(state);
|
||||
setOpen(state);
|
||||
}}>
|
||||
{trigger && (
|
||||
<DialogTrigger asChild>
|
||||
{isCreate ?
|
||||
<Button>
|
||||
<Plus/>{!isMobile && `Add ${channelText} channel`}
|
||||
</Button>
|
||||
:
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<Pencil className="h-4 w-4"/>
|
||||
</Button>
|
||||
}
|
||||
</DialogTrigger>
|
||||
)}
|
||||
<DialogContent onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle> {isCreate ? "Add" : "Edit"} {channelText} Channel</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure your {channelText.toLowerCase()} channel preferences.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div>
|
||||
<>
|
||||
{!isLocalSystem ? (
|
||||
<>
|
||||
{adminView ?
|
||||
<Tabs className="flex flex-col flex-1" defaultValue="configuration">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="configuration">Configuration</TabsTrigger>
|
||||
<TabsTrigger value="organizations">Organizations</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent className="h-full justify-between" value="configuration">
|
||||
<ChannelForm
|
||||
kind={kind}
|
||||
adminView={adminView}
|
||||
defaultValues={channel}
|
||||
organization={organization}
|
||||
onSuccessAction={() => {
|
||||
onOpenChangeAction?.(false)
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent className="h-full justify-between" value="organizations">
|
||||
<ChannelOrganisationForm
|
||||
defaultValues={channel}
|
||||
kind={kind}
|
||||
organizations={organizations}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
:
|
||||
<>
|
||||
<ChannelForm
|
||||
kind={kind}
|
||||
adminView={adminView}
|
||||
defaultValues={channel}
|
||||
organization={organization}
|
||||
onSuccessAction={() => {
|
||||
onOpenChangeAction?.(false)
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
</>
|
||||
)
|
||||
|
||||
:
|
||||
|
||||
<>
|
||||
<ChannelOrganisationForm
|
||||
defaultValues={channel}
|
||||
kind={kind}
|
||||
organizations={organizations}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
|
||||
|
||||
</>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+18
-7
@@ -5,20 +5,31 @@ import {useRouter} from "next/navigation";
|
||||
import {Trash2} from "lucide-react";
|
||||
import {
|
||||
removeNotificationChannelAction,
|
||||
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.action";
|
||||
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/action";
|
||||
import {toast} from "sonner";
|
||||
import {ChannelKind, getChannelTextBasedOnKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
|
||||
import {
|
||||
removeStorageChannelAction
|
||||
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/action";
|
||||
|
||||
export type DeleteNotifierButtonProps = {
|
||||
notificationChannelId: string;
|
||||
export type DeleteChannelButtonProps = {
|
||||
channelId: string;
|
||||
kind: ChannelKind;
|
||||
organizationId?: string;
|
||||
};
|
||||
|
||||
export const DeleteNotifierButton = ({notificationChannelId, organizationId}: DeleteNotifierButtonProps) => {
|
||||
export const DeleteChannelButton = ({channelId, organizationId, kind}: DeleteChannelButtonProps) => {
|
||||
const router = useRouter();
|
||||
const channelText = getChannelTextBasedOnKind(kind)
|
||||
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const result = await removeNotificationChannelAction({organizationId, notificationChannelId})
|
||||
|
||||
const result = kind === "notification" ? await removeNotificationChannelAction({
|
||||
organizationId,
|
||||
notificationChannelId: channelId
|
||||
}) : await removeStorageChannelAction({organizationId, id: channelId})
|
||||
const inner = result?.data;
|
||||
|
||||
if (inner?.success) {
|
||||
@@ -32,8 +43,8 @@ export const DeleteNotifierButton = ({notificationChannelId, organizationId}: De
|
||||
|
||||
return (
|
||||
<ButtonWithConfirm
|
||||
title="Delete Notifier"
|
||||
description="Are you sure you want to remove this notifier ? This action cannot be undone and will delete all alert policies related to this channel !"
|
||||
title={`Delete ${channelText.toLowerCase()} channel`}
|
||||
description={`Are you sure you want to remove this ${channelText.toLowerCase()} channel ? This action cannot be undone and will delete all alert policies related to this channel !`}
|
||||
button={{
|
||||
main: {
|
||||
size: "icon",
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
||||
import {Switch} from "@/components/ui/switch";
|
||||
import {toast} from "sonner";
|
||||
import {useState} from "react";
|
||||
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
||||
import {ChannelKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
|
||||
import {
|
||||
updateNotificationChannelAction
|
||||
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/action";
|
||||
import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal";
|
||||
import {
|
||||
updateStorageChannelAction
|
||||
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/action";
|
||||
|
||||
export type EditChannelButtonProps = {
|
||||
channel: NotificationChannelWith | StorageChannelWith;
|
||||
organization?: OrganizationWithMembers;
|
||||
organizations?: OrganizationWithMembers[];
|
||||
adminView?: boolean;
|
||||
kind: ChannelKind;
|
||||
};
|
||||
|
||||
export const EditChannelButton = ({
|
||||
organizations,
|
||||
adminView = false,
|
||||
channel,
|
||||
organization,
|
||||
kind
|
||||
}: EditChannelButtonProps) => {
|
||||
const router = useRouter();
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const isLocalSystem = channel.provider == "local";
|
||||
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (value: boolean) => {
|
||||
|
||||
|
||||
const payload = {
|
||||
data: {
|
||||
name: channel.name,
|
||||
provider: channel.provider,
|
||||
config: channel.config as Record<string, any>,
|
||||
enabled: value
|
||||
},
|
||||
id: channel.id
|
||||
};
|
||||
|
||||
let result: any;
|
||||
if (kind == "notification") {
|
||||
// @ts-ignore
|
||||
result = await updateNotificationChannelAction(payload)
|
||||
} else if (kind == "storage") {
|
||||
// @ts-ignore
|
||||
result = await updateStorageChannelAction(payload)
|
||||
} else {
|
||||
toast.error("An error occurred while updating storage channel")
|
||||
return;
|
||||
}
|
||||
const inner = result?.data;
|
||||
|
||||
if (inner?.success) {
|
||||
toast.success(inner.actionSuccess?.message);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(inner?.actionError?.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
{!isLocalSystem && (
|
||||
<Switch checked={channel.enabled} onCheckedChange={async () => {
|
||||
await mutation.mutateAsync(!channel.enabled)
|
||||
}}/>
|
||||
)}
|
||||
<ChannelAddEditModal
|
||||
kind={kind}
|
||||
organizations={organizations}
|
||||
adminView={adminView}
|
||||
organization={organization}
|
||||
channel={channel}
|
||||
open={isAddModalOpen}
|
||||
onOpenChangeAction={setIsAddModalOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import {Card} from "@/components/ui/card";
|
||||
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||
import {truncateWords} from "@/utils/text";
|
||||
import {useIsMobile} from "@/hooks/use-mobile";
|
||||
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
||||
import {
|
||||
EditChannelButton
|
||||
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-card/button-edit-channel";
|
||||
import {ChannelKind, getChannelIcon} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
|
||||
import {
|
||||
DeleteChannelButton
|
||||
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-card/button-delete-channel";
|
||||
|
||||
export type ChannelCardProps = {
|
||||
data: NotificationChannelWith | StorageChannelWith;
|
||||
organization?: OrganizationWithMembers;
|
||||
organizations?: OrganizationWithMembers[];
|
||||
adminView?: boolean;
|
||||
kind?: ChannelKind;
|
||||
defaultStorageChannelId?: string | null | undefined
|
||||
|
||||
};
|
||||
|
||||
|
||||
export const ChannelCard = (props: ChannelCardProps) => {
|
||||
const {data, organization, kind, adminView, defaultStorageChannelId} = props;
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const isDefaultSystemStorage = defaultStorageChannelId === data.id;
|
||||
|
||||
const isOwned = data.organizationId ? true : !organization;
|
||||
const isLocalSystem = data.provider == "local";
|
||||
|
||||
return (
|
||||
<div className="block transition-all duration-200 rounded-xl">
|
||||
<Card className="flex flex-row justify-between p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex h-10 w-10 items-center justify-center rounded-md bg-secondary border border-border">
|
||||
{getChannelIcon(data.provider)}
|
||||
</div>
|
||||
<div className={`h-2 w-2 rounded-full ${data.enabled ? "bg-green-600" : "bg-muted"}`}/>
|
||||
</div>
|
||||
<div className="flex justify-start w-full">
|
||||
<div className="flex flex-col items-start md:flex-row md:items-center gap-2 ">
|
||||
<h3 className="font-medium text-foreground">{isMobile ? truncateWords(data.name, 2) : data.name}</h3>
|
||||
<Badge variant="secondary" className="text-xs font-mono">
|
||||
{data.provider}
|
||||
</Badge>
|
||||
{isDefaultSystemStorage && (
|
||||
<Badge variant="secondary" className="text-xs font-mono">
|
||||
default
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{kind && (
|
||||
<div className="flex items-center gap-2">
|
||||
{(isOwned) && (
|
||||
<>
|
||||
<EditChannelButton
|
||||
organizations={props.organizations}
|
||||
adminView={props.adminView}
|
||||
organization={organization}
|
||||
channel={data}
|
||||
kind={kind}
|
||||
/>
|
||||
{!isLocalSystem && (
|
||||
<DeleteChannelButton
|
||||
kind={kind}
|
||||
organizationId={organization?.id}
|
||||
channelId={data.id}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import {z} from "zod";
|
||||
|
||||
|
||||
export const ChannelFormSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(5, "Name must be at least 5 characters long")
|
||||
.max(40, "Name must be at most 40 characters long"),
|
||||
config: z.record(z.union([z.string(), z.number(), z.boolean(), z.null(), z.undefined()])).optional(),
|
||||
enabled: z.boolean().default(true),
|
||||
});
|
||||
|
||||
|
||||
export const NotificationChannelFormSchema = ChannelFormSchema.extend({
|
||||
provider: z.enum(
|
||||
["slack", "smtp", "discord", "telegram", "gotify", "ntfy", "webhook"],
|
||||
{required_error: "Provider is required"}
|
||||
),
|
||||
});
|
||||
|
||||
export const StorageChannelFormSchema = ChannelFormSchema.extend({
|
||||
provider: z.enum(["local", "s3", "google-drive"], {required_error: "Provider is required"}),
|
||||
});
|
||||
|
||||
|
||||
export type NotificationChannelFormType = z.infer<typeof NotificationChannelFormSchema>;
|
||||
export type StorageChannelFormType = z.infer<typeof StorageChannelFormSchema>;
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
"use client";
|
||||
|
||||
import {useRouter} from "next/navigation";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {Input} from "@/components/ui/input";
|
||||
|
||||
import {
|
||||
addStorageChannelAction, updateStorageChannelAction
|
||||
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/action";
|
||||
import {toast} from "sonner";
|
||||
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
||||
import {useEffect} from "react";
|
||||
import {cn} from "@/lib/utils";
|
||||
import {Card} from "@/components/ui/card";
|
||||
import {ArrowLeft} from "lucide-react";
|
||||
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
||||
import {
|
||||
NotificationChannelFormSchema, NotificationChannelFormType, StorageChannelFormSchema, StorageChannelFormType
|
||||
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form.schema";
|
||||
import {
|
||||
ChannelKind,
|
||||
renderChannelForm
|
||||
} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
|
||||
import {storageProviders} from "@/components/wrappers/dashboard/admin/channels/helpers/storage";
|
||||
import {notificationProviders} from "@/components/wrappers/dashboard/admin/channels/helpers/notification";
|
||||
import {
|
||||
ChannelTestButton
|
||||
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-test-button";
|
||||
import {
|
||||
addNotificationChannelAction, updateNotificationChannelAction
|
||||
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/action";
|
||||
|
||||
type NotifierFormProps = {
|
||||
onSuccessAction?: () => void;
|
||||
organization?: OrganizationWithMembers;
|
||||
defaultValues?: NotificationChannelWith | StorageChannelWith
|
||||
adminView?: boolean
|
||||
kind: ChannelKind
|
||||
};
|
||||
|
||||
export const ChannelForm = ({onSuccessAction, organization, defaultValues, kind}: NotifierFormProps) => {
|
||||
|
||||
const router = useRouter();
|
||||
const isCreate = !Boolean(defaultValues);
|
||||
|
||||
const form = useZodForm({
|
||||
schema: kind == "notification" ? NotificationChannelFormSchema : StorageChannelFormSchema,
|
||||
// @ts-ignore
|
||||
defaultValues: {...defaultValues},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(defaultValues ? {...defaultValues} : {});
|
||||
}, [defaultValues]);
|
||||
|
||||
const mutationAddNotificationChannel = useMutation({
|
||||
mutationFn: async (values: NotificationChannelFormType | StorageChannelFormType) => {
|
||||
|
||||
const payload = {
|
||||
data: values,
|
||||
...(organization && {organizationId: organization.id}),
|
||||
...((defaultValues && {id: defaultValues.id}))
|
||||
};
|
||||
|
||||
|
||||
let result: any;
|
||||
|
||||
if (kind === "notification") {
|
||||
// @ts-ignore
|
||||
result = isCreate ? await addNotificationChannelAction(payload) : await updateNotificationChannelAction(payload);
|
||||
} else if (kind === "storage") {
|
||||
// @ts-ignore
|
||||
result = isCreate ? await addStorageChannelAction(payload) : await updateStorageChannelAction(payload);
|
||||
} else {
|
||||
toast.error("An error occurred");
|
||||
return;
|
||||
}
|
||||
|
||||
const inner = result?.data;
|
||||
|
||||
if (inner?.success) {
|
||||
toast.success(inner.actionSuccess?.message);
|
||||
isCreate && onSuccessAction?.();
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(inner?.actionError?.message);
|
||||
isCreate && onSuccessAction?.();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const provider = form.watch("provider");
|
||||
const channelTypes = kind == "notification" ? notificationProviders : storageProviders
|
||||
const selectedProviderDetails = channelTypes.find(t => t.value === provider);
|
||||
|
||||
if (isCreate && !provider) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 py-4">
|
||||
{channelTypes.filter(p => p.value != "local").map((type) => {
|
||||
const Icon = type.icon;
|
||||
return (
|
||||
<Card
|
||||
key={type.value}
|
||||
className={cn(
|
||||
"relative flex flex-col items-center justify-center gap-3 p-4 transition-all border",
|
||||
type.preview
|
||||
? "cursor-not-allowed bg-muted/60 border-border text-muted-foreground"
|
||||
: "cursor-pointer bg-background hover:bg-accent/40 hover:border-primary/50"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (type.preview) return;
|
||||
form.setValue("provider", type.value as any);
|
||||
form.setValue("config", {});
|
||||
}}
|
||||
>
|
||||
|
||||
{type.preview && (
|
||||
<div
|
||||
className="absolute bottom-0 right-0 overflow-hidden w-20 h-20 pointer-events-none">
|
||||
<div
|
||||
className="absolute bottom-0 right-0 w-38 h-5 flex items-center justify-center
|
||||
transform -rotate-45 translate-x-14 -translate-y-4"
|
||||
style={{backgroundColor: "#FE6702"}}
|
||||
>
|
||||
<span className="text-white text-[7px] pr-3 font-medium text-center w-full">COMING SOON</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"h-10 w-10 rounded-full flex items-center justify-center transition-colors",
|
||||
type.preview
|
||||
? "bg-muted"
|
||||
: "bg-accent text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"h-6 w-6",
|
||||
type.preview ? "text-muted-foreground" : "text-foreground"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium text-sm text-center leading-tight",
|
||||
type.preview ? "text-muted-foreground" : "text-foreground"
|
||||
)}
|
||||
>
|
||||
{type.label}
|
||||
</span>
|
||||
</Card>
|
||||
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={async (values) => {
|
||||
await mutationAddNotificationChannel.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2 p-3 bg-secondary/30 rounded-lg border border-border">
|
||||
{selectedProviderDetails && (
|
||||
<div
|
||||
className="h-10 w-10 bg-background rounded-full flex items-center justify-center border border-border shadow-sm">
|
||||
<selectedProviderDetails.icon className="h-5 w-5"/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Configuring {selectedProviderDetails?.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{isCreate ? "New Channel" : "Edit Channel"}</p>
|
||||
</div>
|
||||
{isCreate && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
form.setValue("provider", undefined as any);
|
||||
form.setValue("config", undefined);
|
||||
}}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2"/>
|
||||
Change
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Channel Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder={`My ${selectedProviderDetails?.label} Channel`}
|
||||
value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="provider"
|
||||
render={({field}) => (
|
||||
<input type="hidden" {...field} value={field.value || ""}/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{renderChannelForm(provider, form)}
|
||||
|
||||
<div className="flex justify-between mt-4">
|
||||
<div>
|
||||
{defaultValues && (
|
||||
<ChannelTestButton
|
||||
kind={kind}
|
||||
organizationId={organization?.id}
|
||||
channel={defaultValues}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onSuccessAction?.();
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<ButtonWithLoading isPending={mutationAddNotificationChannel.isPending}>
|
||||
{isCreate ? "Add" : "Save"} Channel
|
||||
</ButtonWithLoading>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
"use client"
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {NotificationChannel} from "@/db/schema/09_notification-channel";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {dispatchNotification} from "@/features/notifications/dispatch";
|
||||
import {EventPayload} from "@/features/notifications/types";
|
||||
import {Send, ShieldCheck} from "lucide-react";
|
||||
import {toast} from "sonner";
|
||||
import {useIsMobile} from "@/hooks/use-mobile";
|
||||
import {cn} from "@/lib/utils";
|
||||
import {StorageChannel} from "@/db/schema/12_storage-channel";
|
||||
import {ChannelKind, getChannelTextBasedOnKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
|
||||
import type {StorageInput} from "@/features/storages/types";
|
||||
import {dispatchStorage} from "@/features/storages/dispatch";
|
||||
|
||||
type NotifierTestChannelButtonProps = {
|
||||
channel: NotificationChannel | StorageChannel;
|
||||
organizationId?: string;
|
||||
kind: ChannelKind;
|
||||
}
|
||||
|
||||
export const ChannelTestButton = ({channel, organizationId, kind}: NotifierTestChannelButtonProps) => {
|
||||
const channelText = getChannelTextBasedOnKind(kind)
|
||||
const isMobile = useIsMobile()
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (kind === "notification") {
|
||||
const payload: EventPayload = {
|
||||
title: 'Test Channel',
|
||||
message: `We are testing channel ${channel.name}`,
|
||||
level: 'info',
|
||||
};
|
||||
const result = await dispatchNotification(payload, undefined, channel.id, organizationId);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error("An error occurred while testing the notification channel, check your configuration");
|
||||
}
|
||||
} else if (kind === "storage") {
|
||||
const input: StorageInput = {
|
||||
action: "ping",
|
||||
};
|
||||
const result = await dispatchStorage(input, undefined, channel.id);
|
||||
|
||||
if (result.success) {
|
||||
toast.success("Successfully connected to storage channel");
|
||||
} else {
|
||||
console.error(result);
|
||||
toast.error("An error occurred while testing the storage channel, check your configuration");
|
||||
}
|
||||
} else {
|
||||
toast.error("Not yet supported");
|
||||
}
|
||||
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
onClick={() => mutation.mutateAsync()}
|
||||
disabled={mutation.isPending}
|
||||
className="bg-green-600 hover:bg-green-700 text-white font-medium shadow-sm transition-all"
|
||||
>
|
||||
{mutation.isPending ? (
|
||||
<>
|
||||
<div
|
||||
className={cn(" h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white", !isMobile && "mr-2")}/>
|
||||
{!isMobile && `Sending...`}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-row justify-center items-center">
|
||||
{kind === "notification" ?
|
||||
<>
|
||||
<Send className={cn("h-4 w-4", !isMobile && "mr-2")}/>{!isMobile && ` Test Channel`}
|
||||
</>
|
||||
:
|
||||
<>
|
||||
<ShieldCheck className={cn("h-4 w-4", !isMobile && "mr-2")}/>{!isMobile && ` Test Storage`}
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
+9
-9
@@ -6,11 +6,11 @@ import * as drizzleDb from "@/db";
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
import {NotificationChannel} from "@/db/schema/09_notification-channel";
|
||||
import {db} from "@/db";
|
||||
import {
|
||||
NotificationChannelFormSchema
|
||||
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.schema";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
import {
|
||||
NotificationChannelFormSchema
|
||||
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form.schema";
|
||||
|
||||
|
||||
export const addNotificationChannelAction = userAction.schema(
|
||||
@@ -20,7 +20,6 @@ export const addNotificationChannelAction = userAction.schema(
|
||||
})
|
||||
).action(async ({parsedInput}): Promise<ServerActionResult<NotificationChannel>> => {
|
||||
const {organizationId, data} = parsedInput;
|
||||
|
||||
try {
|
||||
const [channel] = await db
|
||||
.insert(drizzleDb.schemas.notificationChannel)
|
||||
@@ -29,6 +28,7 @@ export const addNotificationChannelAction = userAction.schema(
|
||||
name: data.name,
|
||||
config: data.config,
|
||||
enabled: data.enabled ?? true,
|
||||
organizationId: organizationId ?? null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
@@ -128,11 +128,11 @@ export const removeNotificationChannelAction = userAction.schema(
|
||||
|
||||
export const updateNotificationChannelAction = userAction.schema(
|
||||
z.object({
|
||||
notificationChannelId: z.string(),
|
||||
id: z.string(),
|
||||
data: NotificationChannelFormSchema
|
||||
})
|
||||
).action(async ({parsedInput}): Promise<ServerActionResult<NotificationChannel>> => {
|
||||
const {notificationChannelId, data} = parsedInput;
|
||||
const {id, data} = parsedInput;
|
||||
|
||||
try {
|
||||
const [channel] = await db
|
||||
@@ -143,7 +143,7 @@ export const updateNotificationChannelAction = userAction.schema(
|
||||
config: data.config,
|
||||
enabled: data.enabled ?? true,
|
||||
}))
|
||||
.where(eq(drizzleDb.schemas.notificationChannel.id, notificationChannelId))
|
||||
.where(eq(drizzleDb.schemas.notificationChannel.id, id))
|
||||
.returning();
|
||||
|
||||
return {
|
||||
@@ -154,7 +154,7 @@ export const updateNotificationChannelAction = userAction.schema(
|
||||
},
|
||||
actionSuccess: {
|
||||
message: `Notification channel "${channel.name}" has been successfully updated.`,
|
||||
messageParams: {notificationChannelId: channel.id},
|
||||
messageParams: {id: channel.id},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -165,7 +165,7 @@ export const updateNotificationChannelAction = userAction.schema(
|
||||
message: "Failed to update notification channel.",
|
||||
status: 500,
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
messageParams: {notificationChannelId: ""},
|
||||
messageParams: {id: ""},
|
||||
},
|
||||
};
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import {UseFormReturn} from "react-hook-form";
|
||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
|
||||
|
||||
type NotifierDiscordFormProps = {
|
||||
form: UseFormReturn<any, any, any>
|
||||
}
|
||||
|
||||
export const NotifierDiscordForm = ({form}: NotifierDiscordFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1"/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.discordWebhook"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Discord Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://discord.com/api/webhooks/..."/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import {UseFormReturn} from "react-hook-form";
|
||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
|
||||
|
||||
type NotifierGotifyFormProps = {
|
||||
form: UseFormReturn<any, any, any>
|
||||
}
|
||||
|
||||
export const NotifierGotifyForm = ({form}: NotifierGotifyFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1"/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.gotifyServerUrl"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Gotify Server URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://gotify.example.com"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.gotifyAppToken"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Application Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="A1b2C3d4E5f6G7h"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
import {UseFormReturn} from "react-hook-form";
|
||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
|
||||
type NotifierNtfyFormProps = {
|
||||
form: UseFormReturn<any, any, any>
|
||||
}
|
||||
|
||||
export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1"/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.ntfyTopic"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Topic Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="my-secret-topic"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.ntfyServerUrl"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Server URL (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://ntfy.sh (default)"/>
|
||||
</FormControl>
|
||||
<p className="text-xs text-muted-foreground">Leave empty to use the official ntfy.sh server.</p>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.ntfyToken"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Access Token (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="tk_..."/>
|
||||
</FormControl>
|
||||
<p className="text-xs text-muted-foreground">Only required for protected topics or self-hosted instances with auth.</p>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Separator className="my-1"/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.ntfyUsername"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Basic Auth Username (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="username"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.ntfyPassword"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Basic Auth Password (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="password" placeholder="password"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import {UseFormReturn} from "react-hook-form";
|
||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
|
||||
|
||||
type NotifierTelegramFormProps = {
|
||||
form: UseFormReturn<any, any, any>
|
||||
}
|
||||
|
||||
export const NotifierTelegramForm = ({form}: NotifierTelegramFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1"/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.telegramBotToken"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Telegram Bot Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.telegramChatId"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Telegram Chat ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="-100123456789 or 123456789"/>
|
||||
</FormControl>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
You must start the conversation with the bot first (<strong>/start</strong>). <br/>
|
||||
For groups, add the bot to the group. <br/>
|
||||
You can use <a href="https://t.me/userinfobot" target="_blank" rel="noopener noreferrer" className="underline">@userinfobot</a> to find your ID.
|
||||
</p>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import {UseFormReturn} from "react-hook-form";
|
||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
|
||||
|
||||
type NotifierWebhookFormProps = {
|
||||
form: UseFormReturn<any, any, any>
|
||||
}
|
||||
|
||||
export const NotifierWebhookForm = ({form}: NotifierWebhookFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1"/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.webhookUrl"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://example.com/api/webhook"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.webhookSecretHeader"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Header Name (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="X-Webhook-Secret"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.webhookSecret"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Secret Value (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Secret value..."/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
If provided, the secret will be sent in the specified header (defaults to <code>X-Webhook-Secret</code>).
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
"use server";
|
||||
|
||||
import {z} from "zod";
|
||||
import {ServerActionResult} from "@/types/action-type";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
import {db} from "@/db";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
import {
|
||||
StorageChannelFormSchema
|
||||
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form.schema";
|
||||
import {StorageChannel} from "@/db/schema/12_storage-channel";
|
||||
|
||||
|
||||
export const addStorageChannelAction = userAction.schema(
|
||||
z.object({
|
||||
organizationId: z.string().optional(),
|
||||
data: StorageChannelFormSchema
|
||||
})
|
||||
).action(async ({parsedInput}): Promise<ServerActionResult<StorageChannel>> => {
|
||||
const {organizationId, data} = parsedInput;
|
||||
|
||||
try {
|
||||
const [channel] = await db
|
||||
.insert(drizzleDb.schemas.storageChannel)
|
||||
.values({
|
||||
provider: data.provider,
|
||||
name: data.name,
|
||||
config: data.config,
|
||||
enabled: data.enabled ?? true,
|
||||
organizationId: organizationId ?? null
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (organizationId) {
|
||||
await db.insert(drizzleDb.schemas.organizationStorageChannel).values({
|
||||
organizationId,
|
||||
storageChannelId: channel.id,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: {
|
||||
...channel,
|
||||
config: channel.config as JSON
|
||||
},
|
||||
actionSuccess: {
|
||||
message: "Storage channel has been successfully created.",
|
||||
messageParams: {id: channel.id},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Failed to create storage channel.",
|
||||
status: 500,
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
messageParams: {id: ""},
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const removeStorageChannelAction = userAction.schema(
|
||||
z.object({
|
||||
organizationId: z.string().optional(),
|
||||
id: z.string(),
|
||||
})
|
||||
).action(async ({parsedInput}): Promise<ServerActionResult<StorageChannel>> => {
|
||||
const {organizationId, id} = parsedInput;
|
||||
|
||||
try {
|
||||
if (organizationId) {
|
||||
await db
|
||||
.delete(drizzleDb.schemas.organizationStorageChannel)
|
||||
.where(
|
||||
and(
|
||||
eq(drizzleDb.schemas.organizationStorageChannel.organizationId, organizationId),
|
||||
eq(drizzleDb.schemas.organizationStorageChannel.storageChannelId, id)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [deletedChannel] = await db
|
||||
.delete(drizzleDb.schemas.storageChannel)
|
||||
.where(eq(drizzleDb.schemas.storageChannel.id, id))
|
||||
.returning();
|
||||
|
||||
if (!deletedChannel) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Storage channel not found.",
|
||||
status: 404,
|
||||
messageParams: {id: id},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: {
|
||||
...deletedChannel,
|
||||
config: deletedChannel.config as JSON
|
||||
},
|
||||
actionSuccess: {
|
||||
message: "Storage channel has been successfully removed.",
|
||||
messageParams: {id: id},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Failed to remove storage channel.",
|
||||
status: 500,
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
messageParams: {id: id},
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
export const updateStorageChannelAction = userAction.schema(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
data: StorageChannelFormSchema
|
||||
})
|
||||
).action(async ({parsedInput}): Promise<ServerActionResult<StorageChannel>> => {
|
||||
const {id, data} = parsedInput;
|
||||
|
||||
try {
|
||||
const [channel] = await db
|
||||
.update(drizzleDb.schemas.storageChannel)
|
||||
.set(withUpdatedAt({
|
||||
provider: data.provider,
|
||||
name: data.name,
|
||||
config: data.config,
|
||||
enabled: data.enabled ?? true,
|
||||
}))
|
||||
.where(eq(drizzleDb.schemas.storageChannel.id, id))
|
||||
.returning();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: {
|
||||
...channel,
|
||||
config: channel.config as JSON
|
||||
},
|
||||
actionSuccess: {
|
||||
message: `Storage channel "${channel.name}" has been successfully updated.`,
|
||||
messageParams: {id: channel.id},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Failed to update storage channel.",
|
||||
status: 500,
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
messageParams: {id: ""},
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import {UseFormReturn} from "react-hook-form";
|
||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
import {
|
||||
googleDriveRefreshTokenAction
|
||||
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/forms/google-drive/helpers";
|
||||
import {toast} from "sonner";
|
||||
|
||||
type StorageGoogleDriveFormProps = {
|
||||
form: UseFormReturn<any>;
|
||||
};
|
||||
|
||||
export const StorageGoogleDriveForm = ({form}: StorageGoogleDriveFormProps) => {
|
||||
const refreshToken = form.watch("config.refreshToken");
|
||||
const isConnected = Boolean(refreshToken);
|
||||
|
||||
const handleConnect = () => {
|
||||
const clientId = form.getValues("config.clientId");
|
||||
const clientSecret = form.getValues("config.clientSecret");
|
||||
const redirectUri = `${window.location.origin}/api/google/drive/callback`;
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
form.setError("config.clientId", {message: "Client ID and Secret are required"});
|
||||
return;
|
||||
}
|
||||
|
||||
const scope = encodeURIComponent("https://www.googleapis.com/auth/drive.file");
|
||||
const oauthUrl =
|
||||
`https://accounts.google.com/o/oauth2/v2/auth` +
|
||||
`?client_id=${clientId}` +
|
||||
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
||||
`&response_type=code` +
|
||||
`&scope=${scope}` +
|
||||
`&access_type=offline` +
|
||||
`&prompt=consent`;
|
||||
|
||||
const oauthWindow = window.open(oauthUrl, "_blank", "width=500,height=600");
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
if (!oauthWindow || oauthWindow.closed) {
|
||||
clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
|
||||
if (oauthWindow.location.href.startsWith(redirectUri)) {
|
||||
const code = new URL(oauthWindow.location.href).searchParams.get("code");
|
||||
if (!code) return;
|
||||
|
||||
const result = await googleDriveRefreshTokenAction({
|
||||
code,
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUri,
|
||||
});
|
||||
const inner = result?.data;
|
||||
|
||||
if (inner?.success) {
|
||||
toast.success(inner.actionSuccess?.message);
|
||||
|
||||
if (!inner.value) {
|
||||
form.setError("config", {message: "OAuth succeeded but no refresh token returned"});
|
||||
return;
|
||||
}
|
||||
|
||||
form.setValue("config.refreshToken", inner.value, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
|
||||
} else {
|
||||
toast.error(inner?.actionError?.message);
|
||||
}
|
||||
|
||||
|
||||
oauthWindow.close();
|
||||
clearInterval(interval);
|
||||
}
|
||||
} catch {
|
||||
// Ignore cross-origin errors until redirect
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1"/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.clientId"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Client ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="xxxx.apps.googleusercontent.com"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.clientSecret"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Client Secret</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.folderId"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Folder ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<input type="hidden" {...form.register("config.refreshToken")} />
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="button" onClick={handleConnect}>
|
||||
{isConnected ? "Reconnect Google Drive" : "Connect Google Drive"}
|
||||
</Button>
|
||||
|
||||
{isConnected && (
|
||||
<span className="text-sm text-green-600 font-medium">
|
||||
Google Drive connected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
"use server"
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
import {z} from "zod";
|
||||
import {ServerActionResult} from "@/types/action-type";
|
||||
|
||||
|
||||
export const googleDriveRefreshTokenAction = userAction.schema(
|
||||
z.object({
|
||||
code: z.string(),
|
||||
clientId: z.string(),
|
||||
clientSecret: z.string(),
|
||||
redirectUri: z.string(),
|
||||
})).action(async ({parsedInput}): Promise<ServerActionResult<string>> => {
|
||||
const {code, clientId, clientSecret, redirectUri} = parsedInput;
|
||||
|
||||
try {
|
||||
|
||||
const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
code: code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
});
|
||||
|
||||
const tokens = await tokenRes.json();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: tokens.refresh_token,
|
||||
actionSuccess: {
|
||||
message: "Refresh token successfully fetched",
|
||||
messageParams: {code: code},
|
||||
},
|
||||
};
|
||||
}catch {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "An error occurred",
|
||||
status: 404,
|
||||
messageParams: {code: code},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
});
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import {UseFormReturn} from "react-hook-form";
|
||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
import {Switch} from "@/components/ui/switch";
|
||||
|
||||
|
||||
type StorageS3FormProps = {
|
||||
form: UseFormReturn<any, any, any>
|
||||
}
|
||||
|
||||
export const StorageS3Form = ({form}: StorageS3FormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1"/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.endPointUrl"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Endpoind URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="s3.exemple.com"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.accessKey"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Access Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="A1b2C3d4E5f6G7h"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.secretKey"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Secret Key</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} placeholder="A1b2C3d4E5f6G7h"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.bucketName"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bucket name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="portabase-dev"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.port"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="number" placeholder="443" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.useSSL"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Use SSL</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client"
|
||||
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
||||
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
|
||||
import {useState} from "react";
|
||||
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
|
||||
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
||||
import {ChannelCard} from "@/components/wrappers/dashboard/admin/channels/channel/channel-card/channel-card";
|
||||
import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal";
|
||||
import {ChannelKind, getChannelTextBasedOnKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
|
||||
|
||||
type ChannelsSectionProps = {
|
||||
channels: NotificationChannelWith[] | StorageChannelWith[],
|
||||
organizations: OrganizationWithMembers[],
|
||||
kind: ChannelKind,
|
||||
defaultStorageChannelId?: string | null | undefined
|
||||
}
|
||||
|
||||
export const ChannelsSection = ({
|
||||
organizations,
|
||||
channels,
|
||||
kind,
|
||||
defaultStorageChannelId
|
||||
}: ChannelsSectionProps) => {
|
||||
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const channelText = getChannelTextBasedOnKind(kind)
|
||||
const hasChannels = channels.length > 0
|
||||
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<ChannelAddEditModal kind={kind} open={isAddModalOpen} onOpenChangeAction={setIsAddModalOpen}
|
||||
adminView={false}
|
||||
trigger={false}/>
|
||||
{hasChannels ? (
|
||||
<div className="h-full">
|
||||
<CardsWithPagination
|
||||
data={channels}
|
||||
cardItem={ChannelCard}
|
||||
cardsPerPage={8}
|
||||
numberOfColumns={2}
|
||||
adminView={true}
|
||||
organizations={organizations}
|
||||
kind={kind}
|
||||
defaultStorageChannelId={defaultStorageChannelId}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyStatePlaceholder
|
||||
text={`No ${channelText} channels configured yet`}
|
||||
onClick={() => {
|
||||
setIsAddModalOpen(true)
|
||||
}}
|
||||
className="h-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import {UseFormReturn} from "react-hook-form";
|
||||
import {
|
||||
NotifierSmtpForm
|
||||
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/smtp.form";
|
||||
import {
|
||||
NotifierSlackForm
|
||||
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/slack.form";
|
||||
import {
|
||||
NotifierDiscordForm
|
||||
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/discord.form";
|
||||
import {
|
||||
NotifierTelegramForm
|
||||
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/telegram.form";
|
||||
import {
|
||||
NotifierGotifyForm
|
||||
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/gotify.form";
|
||||
import {
|
||||
NotifierNtfyForm
|
||||
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/ntfy.form";
|
||||
import {
|
||||
NotifierWebhookForm
|
||||
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/webhook.form";
|
||||
import {
|
||||
notificationProviders,
|
||||
} from "@/components/wrappers/dashboard/admin/channels/helpers/notification";
|
||||
import {storageProviders} from "@/components/wrappers/dashboard/admin/channels/helpers/storage";
|
||||
import {ForwardRefExoticComponent, JSX, RefAttributes, SVGProps} from "react";
|
||||
import {LucideProps} from "lucide-react";
|
||||
import {
|
||||
StorageS3Form
|
||||
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/forms/s3.form";
|
||||
import {
|
||||
StorageGoogleDriveForm
|
||||
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/forms/google-drive.form";
|
||||
|
||||
export type ChannelKind = "notification" | "storage";
|
||||
|
||||
export function getChannelTextBasedOnKind(kind: ChannelKind) {
|
||||
switch (kind) {
|
||||
case "notification":
|
||||
return "Notification";
|
||||
case "storage":
|
||||
return "Storage";
|
||||
default:
|
||||
return "Notification";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export type ProviderIconTypes = {
|
||||
value: string
|
||||
label: string
|
||||
icon: ForwardRefExoticComponent<Omit<LucideProps, "ref"> & RefAttributes<SVGSVGElement>>
|
||||
preview?: boolean
|
||||
} | {
|
||||
value: string
|
||||
label: string
|
||||
icon: (props: SVGProps<SVGSVGElement>) => JSX.Element
|
||||
preview?: boolean
|
||||
}
|
||||
|
||||
export const providerIcons: ProviderIconTypes[] = [
|
||||
...notificationProviders,
|
||||
...storageProviders,
|
||||
];
|
||||
|
||||
|
||||
export const getChannelIcon = (type: string) => {
|
||||
const Icon = providerIcons.find((t) => t.value === type)?.icon
|
||||
return Icon ? <Icon className="h-4 w-4"/> : null
|
||||
}
|
||||
|
||||
|
||||
export const renderChannelForm = (provider: string | undefined, form: UseFormReturn<any>) => {
|
||||
switch (provider) {
|
||||
case "smtp":
|
||||
return <NotifierSmtpForm form={form}/>;
|
||||
case "slack":
|
||||
return <NotifierSlackForm form={form}/>;
|
||||
case "discord":
|
||||
return <NotifierDiscordForm form={form}/>;
|
||||
case "telegram":
|
||||
return <NotifierTelegramForm form={form}/>;
|
||||
case "gotify":
|
||||
return <NotifierGotifyForm form={form}/>;
|
||||
case "ntfy":
|
||||
return <NotifierNtfyForm form={form}/>;
|
||||
case "webhook":
|
||||
return <NotifierWebhookForm form={form}/>;
|
||||
case "s3":
|
||||
return <StorageS3Form form={form}/>
|
||||
case "google-drive":
|
||||
return <StorageGoogleDriveForm form={form}/>
|
||||
case "local":
|
||||
return <></>
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,532 @@
|
||||
import {Mail} from "lucide-react";
|
||||
import type { SVGProps } from 'react';
|
||||
import {ProviderIconTypes} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
|
||||
|
||||
|
||||
|
||||
|
||||
export const notificationProviders: ProviderIconTypes[] = [
|
||||
{value: "smtp", label: "Email", icon: Mail},
|
||||
{value: "slack", label: "Slack", icon: SlackIcon},
|
||||
{value: "discord", label: "Discord", icon: DiscordIcon},
|
||||
{value: "telegram", label: "Telegram", icon: TelegramIcon},
|
||||
{value: "gotify", label: "Gotify", icon: GotifyIcon},
|
||||
{value: "ntfy", label: "ntfy.sh", icon: NtfyIcon},
|
||||
{value: "webhook", label: "Webhook", icon: WebhookIcon},
|
||||
{value: "microsoft-teams", label: "Microsoft Teams", icon: MSTeamsIcon, preview: true}
|
||||
]
|
||||
|
||||
|
||||
export function SlackIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (<svg xmlns="http://www.w3.org/2000/svg" width={256} height={256} viewBox="0 0 256 256" {...props}><path fill="#e01e5a" d="M53.841 161.32c0 14.832-11.987 26.82-26.819 26.82S.203 176.152.203 161.32c0-14.831 11.987-26.818 26.82-26.818H53.84zm13.41 0c0-14.831 11.987-26.818 26.819-26.818s26.819 11.987 26.819 26.819v67.047c0 14.832-11.987 26.82-26.82 26.82c-14.83 0-26.818-11.988-26.818-26.82z"></path><path fill="#36c5f0" d="M94.07 53.638c-14.832 0-26.82-11.987-26.82-26.819S79.239 0 94.07 0s26.819 11.987 26.819 26.819v26.82zm0 13.613c14.832 0 26.819 11.987 26.819 26.819s-11.987 26.819-26.82 26.819H26.82C11.987 120.889 0 108.902 0 94.069c0-14.83 11.987-26.818 26.819-26.818z"></path><path fill="#2eb67d" d="M201.55 94.07c0-14.832 11.987-26.82 26.818-26.82s26.82 11.988 26.82 26.82s-11.988 26.819-26.82 26.819H201.55zm-13.41 0c0 14.832-11.988 26.819-26.82 26.819c-14.831 0-26.818-11.987-26.818-26.82V26.82C134.502 11.987 146.489 0 161.32 0s26.819 11.987 26.819 26.819z"></path><path fill="#ecb22e" d="M161.32 201.55c14.832 0 26.82 11.987 26.82 26.818s-11.988 26.82-26.82 26.82c-14.831 0-26.818-11.988-26.818-26.82V201.55zm0-13.41c-14.831 0-26.818-11.988-26.818-26.82c0-14.831 11.987-26.818 26.819-26.818h67.25c14.832 0 26.82 11.987 26.82 26.819s-11.988 26.819-26.82 26.819z"></path></svg>);
|
||||
}
|
||||
|
||||
export function DiscordIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (<svg xmlns="http://www.w3.org/2000/svg" width={256} height={199} viewBox="0 0 256 199" {...props}><path fill="#5865f2" d="M216.856 16.597A208.5 208.5 0 0 0 164.042 0c-2.275 4.113-4.933 9.645-6.766 14.046q-29.538-4.442-58.533 0c-1.832-4.4-4.55-9.933-6.846-14.046a207.8 207.8 0 0 0-52.855 16.638C5.618 67.147-3.443 116.4 1.087 164.956c22.169 16.555 43.653 26.612 64.775 33.193A161 161 0 0 0 79.735 175.3a136.4 136.4 0 0 1-21.846-10.632a109 109 0 0 0 5.356-4.237c42.122 19.702 87.89 19.702 129.51 0a132 132 0 0 0 5.355 4.237a136 136 0 0 1-21.886 10.653c4.006 8.02 8.638 15.67 13.873 22.848c21.142-6.58 42.646-16.637 64.815-33.213c5.316-56.288-9.08-105.09-38.056-148.36M85.474 135.095c-12.645 0-23.015-11.805-23.015-26.18s10.149-26.2 23.015-26.2s23.236 11.804 23.015 26.2c.02 14.375-10.148 26.18-23.015 26.18m85.051 0c-12.645 0-23.014-11.805-23.014-26.18s10.148-26.2 23.014-26.2c12.867 0 23.236 11.804 23.015 26.2c0 14.375-10.148 26.18-23.015 26.18"></path></svg>);
|
||||
}
|
||||
|
||||
export function TelegramIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (<svg xmlns="http://www.w3.org/2000/svg" width={256} height={256} viewBox="0 0 256 256" {...props}><defs><linearGradient id="SVGuySfwdaH" x1="50%" x2="50%" y1="0%" y2="100%"><stop offset="0%" stopColor="#2aabee"></stop><stop offset="100%" stopColor="#229ed9"></stop></linearGradient></defs><path fill="url(#SVGuySfwdaH)" d="M128 0C94.06 0 61.48 13.494 37.5 37.49A128.04 128.04 0 0 0 0 128c0 33.934 13.5 66.514 37.5 90.51C61.48 242.506 94.06 256 128 256s66.52-13.494 90.5-37.49c24-23.996 37.5-56.576 37.5-90.51s-13.5-66.514-37.5-90.51C194.52 13.494 161.94 0 128 0"></path><path fill="#fff" d="M57.94 126.648q55.98-24.384 74.64-32.152c35.56-14.786 42.94-17.354 47.76-17.441c1.06-.017 3.42.245 4.96 1.49c1.28 1.05 1.64 2.47 1.82 3.467c.16.996.38 3.266.2 5.038c-1.92 20.24-10.26 69.356-14.5 92.026c-1.78 9.592-5.32 12.808-8.74 13.122c-7.44.684-13.08-4.912-20.28-9.63c-11.26-7.386-17.62-11.982-28.56-19.188c-12.64-8.328-4.44-12.906 2.76-20.386c1.88-1.958 34.64-31.748 35.26-34.45c.08-.338.16-1.598-.6-2.262c-.74-.666-1.84-.438-2.64-.258c-1.14.256-19.12 12.152-54 35.686c-5.1 3.508-9.72 5.218-13.88 5.128c-4.56-.098-13.36-2.584-19.9-4.708c-8-2.606-14.38-3.984-13.82-8.41c.28-2.304 3.46-4.662 9.52-7.072"></path></svg>);
|
||||
}
|
||||
|
||||
|
||||
export function NtfyIcon(props: SVGProps<SVGSVGElement>) {
|
||||
|
||||
return ( <svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlSpace="preserve"
|
||||
id="Layer_1"
|
||||
x={0}
|
||||
y={0}
|
||||
viewBox="0 0 512 512"
|
||||
{...props}
|
||||
>
|
||||
<style>{".st1{fill:#fff}"}</style>
|
||||
<linearGradient
|
||||
id="SVGID_1_"
|
||||
x1={115.045}
|
||||
x2={142.93}
|
||||
y1={512.747}
|
||||
y2={491.741}
|
||||
gradientTransform="matrix(15.8584 0 0 -15.8584 -1816.246 8200.18)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop
|
||||
offset={0}
|
||||
style={{
|
||||
stopColor: "#348878",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={1}
|
||||
style={{
|
||||
stopColor: "#56bda8",
|
||||
}}
|
||||
/>
|
||||
</linearGradient>
|
||||
<path
|
||||
d="M441.8 64.8H84.7c-24.5 0-44.6 19-44.6 42.1l.3 285.3-6.7 38.4 95.6-28.8h312.4c24.5 0 44.6-19 44.6-42.1V106.9c.1-23.2-20-42.1-44.5-42.1"
|
||||
style={{
|
||||
fill: "url(#SVGID_1_)",
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="M78.1 32.7C40.9 32.7 9 62 9 99.2l.4 311.2-9.4 69 127.1-33.8H443c37.2 0 69.1-29.3 69.1-66.5V99.2c0-37.2-31.9-66.5-69-66.5zm0 46.9H443c13.1 0 22.1 9.5 22.1 19.6V379c0 10-9 19.8-22.1 19.6H120.2l-64.6 19.5.7-3.8-.4-315.1c0-10.1 9.1-19.6 22.2-19.6"
|
||||
className="st1"
|
||||
/>
|
||||
<path
|
||||
d="M116.4 310.9v-34.8l73.1-38.7c2.4-1.2 4.6-2.1 6.8-2.7 2.3-.8 4.5-1.4 6.3-1.8l6-.6V230q-3-.3-6-1.2-2.85-.6-6.3-1.5c-2.2-.8-4.5-1.8-6.8-3l-73.1-38.7v-35.1l113 61.5v37.4z"
|
||||
className="st1"
|
||||
/>
|
||||
<path
|
||||
d="M110.5 139.7v50.4l76.6 40.6.5.2-.5.2-76.6 40.6v50.1l8.2-4.5L235.1 254v-46.4zm11.8 21.7 101.2 55.1V245l-101.2 55.1v-19.4l69.6-36.8.1-.1c1.9-1 3.8-1.7 5.8-2.3h.1l.2-.1c2.1-.7 3.9-1.2 5.5-1.6l11.1-1.1v-15.2l-5.4-.5c-1.7-.2-3.5-.5-5.1-1.1h-.2l-.2-.1c-2-.4-3.9-.9-5.9-1.4-2-.7-4-1.6-5.9-2.6l-.1-.1-69.6-36.7zm152.3 137.4h120.7v32.6H274.6z"
|
||||
className="st1"
|
||||
/>
|
||||
<path
|
||||
d="M268.7 292.1v46h132.4v-46zm11.7 13.4h109v19.2h-109z"
|
||||
className="st1"
|
||||
/>
|
||||
</svg>)
|
||||
|
||||
}
|
||||
|
||||
|
||||
export function GotifyIcon(props: SVGProps<SVGSVGElement>) {
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlSpace="preserve"
|
||||
id="Layer_2"
|
||||
x={0}
|
||||
y={0}
|
||||
viewBox="46.6 113.46 377.2 307.81"
|
||||
{...props}
|
||||
>
|
||||
<style>
|
||||
{
|
||||
".st0{fill:#ddcba2}.st0,.st1,.st2,.st4,.st5{stroke:#000;stroke-width:2;stroke-linecap:round;stroke-miterlimit:10}.st1{fill:#71caee}.st2{fill:#fff}.st4{fill:#f0f0f0}.st5{fill:none}"
|
||||
}
|
||||
</style>
|
||||
<switch>
|
||||
<foreignObject
|
||||
width={1}
|
||||
height={1}
|
||||
x={0}
|
||||
y={0}
|
||||
requiredExtensions="http://ns.adobe.com/AdobeIllustrator/10.0/"
|
||||
/>
|
||||
<g>
|
||||
<path
|
||||
d="M317.7 376.2c6.2-1.7 15.8 0 19.5 3.2 5.5 4.8 4.9 20.9 1.1 29-.8 1.7-1.8 3.4-3.2 4.4s-3.4 1.2-4.7 0-1.7-3.3-1.9-5.2c-.2-3.1-.2-6.2.3-9.2.2-1.3.2-2.9-.2-4.2-.6-2.2-2.5-3.1-4.5-3.5-3.4-.8-14.3-.7-19.5-.7-.8 0 1.5-9.1 2-9.7 2.6-2.9 7.5-3.1 11.1-4.1z"
|
||||
className="st0"
|
||||
/>
|
||||
<path
|
||||
d="m258.9 119.7-9-2.7c-4.6-1.4-9.2-2.8-14-2.5-2.8.2-6.1 1.3-6.9 4-.6 2-1.6 7.3-1.3 7.9 1.5 3.4 13.9 6.7 18.3 6.7"
|
||||
className="st1"
|
||||
/>
|
||||
<path d="M392.6 177.9c-1.4 1.4-2.2 3.5-2.5 5.5-.2 1.4-.1 3 .5 4.3s1.8 2.3 3.1 3c1.3.6 2.8.9 4.3.9 1.1 0 2.3-.1 3.1-.9.6-.7.8-1.6.9-2.5.2-2.3-.1-4.7-.9-6.9-.4-1.1-.9-2.3-1.8-3.1-1.7-1.8-4.5-2.2-6.4-.5-.1 0-.2.1-.3.2" />
|
||||
<path
|
||||
d="M358.5 164.2c-1-1 0-2.7 1-3.7 5.8-5.2 15.1-4.6 21.8-.6 10.9 6.6 15.6 19.9 17.2 32.5.6 5.2.9 10.6-.5 15.7s-4.6 9.9-9.3 12.1c-1.1.5-2.3.9-3.4.5s-1.9-1.8-1.2-2.8c-9.4-13.6-19-26.8-20.9-43.2-.5-4.1-1.8-7.4-4.7-10.5z"
|
||||
className="st2"
|
||||
/>
|
||||
<path
|
||||
d="M134.7 328.4c-5.1-3.1-9.9-6.6-14.3-10.6-1.3-1.2-2.6-2.5-2.6-4.3 0-1.2.6-2.2 1.2-3.2.8-1.4 1.7-2.8 2.5-4.1 1.1-1.8 2.9-3.9 4.9-3.2.9.3 1.5 1.1 2 1.8 2.4 3.3 4.9 6.6 7.3 9.8 1.5 2 3.7 4.3 6.1 3.5"
|
||||
className="st1"
|
||||
/>
|
||||
<path
|
||||
d="M209.6 133c33.2-18 77.8-19.6 111.5-8.7 24.3 7.9 43.4 26.7 53.3 50 8.7 20.6 10.5 43.6 8.1 65.7-4.4 40.2-20.2 77.9-40.3 112.6-11.1 19-21.8 36-40.5 48.5-36.8 24.6-87.2 22.1-128.4 11.5-19.9-5.1-39.7-17.3-47.2-37.3-4.8-12.8-4.2-27.6 1.5-40 11.6-24.8 43.2-38.4 45.6-67.9.7-8.7-1.6-17.3-3.6-25.7-5.6-23.4-8.9-45.8 1.4-68.7 8.1-17.7 21.9-31 38.6-40z"
|
||||
className="st1"
|
||||
/>
|
||||
<path
|
||||
d="M189.8 151.4c-5.4-5.2-11.9-8.8-19-10.3-2.2-.5-4.7-.7-6.9.7-1.8 1.2-3.1 3.3-4.2 5.3-1.6 3-3 6.2-4.1 9.4-.4 1.2-.6 2.5 0 3.5.3.6.9.9 1.5 1.2 8.1 4.2 16.8 7.1 25.5 9.8"
|
||||
className="st1"
|
||||
/>
|
||||
<path
|
||||
d="M183.7 158.7c-2.5-1.8-16.8-12.1-18.7-4.8-.4 1.6.5 3.9 1.5 4.8"
|
||||
className="st1"
|
||||
/>
|
||||
<path
|
||||
d="M264.5 174.9c-.5.5-.9 1-1.3 1.6-9 11.6-12 27.9-9.3 42.1 1.7 9 5.9 17.9 13.2 23.4 19.3 14.6 51.5 13.5 68.4-1.5 24.4-21.7 13-67.6-14-78.8-17.6-7.2-43.7-1.6-57 13.2zm117.6 62.2c1.4-.1 2.9-.1 4.3.1.3 0 .7.1 1 .4.2.3.4.7.5 1.1 1 3.9.5 8.2.1 12.4-.1.9-.2 1.8-.6 2.6-1 2.1-3.1 2.7-4.7 2.7-.1 0-.2 0-.3-.1-.3-.2-.3-.7-.2-1.2.3-5.9-.1-11.9-.1-18q0 .15 0 0z"
|
||||
className="st2"
|
||||
/>
|
||||
<path
|
||||
d="M378.7 236.8c-1.4.4-2.5 2-2.8 4.4-.5 4.4-.7 8.9-.5 13.4 0 .9.1 1.9.5 2.4.2.3.5.4.8.4 1.6.3 4.1-.6 5.6-1 0 0 0-5.2-.1-8s-.1-6.1-.2-8.9v-2.2c.1-.7-2.6-.7-3.3-.5z"
|
||||
className="st2"
|
||||
/>
|
||||
<path
|
||||
d="M358.3 231.8c-.3 2.2.1 4.7 1.7 7.4 2.6 4.4 7 6.1 11.9 5.8 8.9-.6 25.3-5.4 27.5-15.7.6-3-.3-6.1-2.2-8.5-6.2-7.8-17.8-5.7-25.6-2-5.9 2.7-12.4 7-13.3 13z"
|
||||
className="st0"
|
||||
/>
|
||||
<path
|
||||
d="M386.4 208.6c2.2 1.4 3.7 3.8 4 7 .3 3.6-1.4 7.5-5 8.8-2.9 1.1-6.2.6-9.1-.4s-5.8-2.8-6.8-5.7c-.7-2-.3-4.3.7-6.1 1.1-1.8 2.8-3.2 4.7-4.1 3.9-1.8 8.4-1.6 11.5.5z"
|
||||
style={{
|
||||
fill: "#888e93",
|
||||
stroke: "#000",
|
||||
strokeWidth: 2,
|
||||
strokeLinecap: "round",
|
||||
strokeMiterlimit: 10,
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="M414.7 262.6c2.4.6 4.8 2.1 5.6 4.4s.1 4.9-1.6 6.7-4.2 2.5-6.6 2.5c-.8 0-1.7-.1-2.4-.5-2.5-1.1-3.5-4-4.2-6.6-1.8-6.8 3.6-7.8 9.2-6.5z"
|
||||
className="st0"
|
||||
/>
|
||||
<path
|
||||
d="M267.1 284.7c2.3-4.5 141.3-36.2 144.7-31.6 3.4 4.5 15.8 88.2 9 90.4-6.8 2.3-119.8 37.3-126.6 35s-29.4-89.3-27.1-93.8z"
|
||||
className="st4"
|
||||
/>
|
||||
<path
|
||||
d="M294.2 378.5s54.3-74.6 59.9-76.9c5.7-2.3 67.3 41.3 67.3 41.3"
|
||||
className="st5"
|
||||
/>
|
||||
<path
|
||||
d="M267 287.7s86 38.8 91.6 36.6c5.7-2.3 53.1-71.2 53.1-71.2"
|
||||
className="st4"
|
||||
/>
|
||||
<linearGradient
|
||||
id="SVGID_1_"
|
||||
x1={131.119}
|
||||
x2={103.259}
|
||||
y1={373.573}
|
||||
y2={396.951}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop
|
||||
offset={0}
|
||||
style={{
|
||||
stopColor: "#71caee",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.041}
|
||||
style={{
|
||||
stopColor: "#83cae2",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.121}
|
||||
style={{
|
||||
stopColor: "#9fcace",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.211}
|
||||
style={{
|
||||
stopColor: "#b6cbbe",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.314}
|
||||
style={{
|
||||
stopColor: "#c7cbb1",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.438}
|
||||
style={{
|
||||
stopColor: "#d4cba8",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.606}
|
||||
style={{
|
||||
stopColor: "#dbcba3",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={1}
|
||||
style={{
|
||||
stopColor: "#ddcba2",
|
||||
}}
|
||||
/>
|
||||
</linearGradient>
|
||||
<path
|
||||
d="M132.8 375.6c-3.5 3.8-7.3 7.8-13 9.2-4.6 1.2-10 .2-13.6-2.3-1.4-1-2.6-2.2-4-3.2-1.5-1-3.4-1.7-5.3-1.3-2.7.5-4.1 3.1-3.6 5.3 2 8.8 17 15.6 27.5 15.5 9 0 19-4.6 21.4-11.8"
|
||||
style={{
|
||||
fill: "url(#SVGID_1_)",
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="M132.8 375.6c-3.5 3.8-7.3 7.8-13 9.2-4.6 1.2-10 .2-13.6-2.3-1.4-1-2.6-2.2-4-3.2-1.5-1-3.4-1.7-5.3-1.3-2.7.5-4.1 3.1-3.6 5.3 2 8.8 17 15.6 27.5 15.5 9 0 19-4.6 21.4-11.8"
|
||||
className="st5"
|
||||
/>
|
||||
<linearGradient
|
||||
id="SVGID_2_"
|
||||
x1={265.033}
|
||||
x2={275.301}
|
||||
y1={280.295}
|
||||
y2={302.314}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop
|
||||
offset={0}
|
||||
style={{
|
||||
stopColor: "#71caee",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.041}
|
||||
style={{
|
||||
stopColor: "#83cae2",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.121}
|
||||
style={{
|
||||
stopColor: "#9fcace",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.211}
|
||||
style={{
|
||||
stopColor: "#b6cbbe",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.314}
|
||||
style={{
|
||||
stopColor: "#c7cbb1",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.438}
|
||||
style={{
|
||||
stopColor: "#d4cba8",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.606}
|
||||
style={{
|
||||
stopColor: "#dbcba3",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={1}
|
||||
style={{
|
||||
stopColor: "#ddcba2",
|
||||
}}
|
||||
/>
|
||||
</linearGradient>
|
||||
<path
|
||||
d="M261.9 283.5c-.1 4.2 4.3 7.3 8.4 7.6s8.2-1.3 12.2-2.6c1.4-.4 2.9-.8 4.2-.2 1.8.9 2.7 4.1 1.8 5.9s-3.4 3.5-5.3 4.4c-6.5 3-12.9 3.6-19.9 2-5.3-1.2-11.3-4.3-13-13.5"
|
||||
style={{
|
||||
fill: "url(#SVGID_2_)",
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="M261.9 283.5c-.1 4.2 4.3 7.3 8.4 7.6s8.2-1.3 12.2-2.6c1.4-.4 2.9-.8 4.2-.2 1.8.9 2.7 4.1 1.8 5.9s-3.4 3.5-5.3 4.4c-6.5 3-12.9 3.6-19.9 2-5.3-1.2-11.3-4.3-13-13.5"
|
||||
className="st5"
|
||||
/>
|
||||
<path d="M318.4 198.4c-2-.3-4.1.1-5.9 1.3-3.2 2.1-4.7 6.2-4.7 9.9 0 1.9.4 3.8 1.4 5.3 1.2 1.7 3.1 2.9 5.2 3.4 3.4.8 8.2.7 10.5-2.5 1-1.5 1.4-3.3 1.5-5.1.5-5.7-1.8-11.4-8-12.3" />
|
||||
<path
|
||||
d="M320.4 203.3c.9.3 1.7.8 2.1 1.7.4.8.4 1.7.3 2.5-.1 1-.6 2-1.5 2.7-.7.5-1.7.7-2.6.5s-1.7-.8-2.2-1.6c-1.1-1.6-.9-4.4.9-5.5.9-.4 2-.6 3-.3"
|
||||
style={{
|
||||
fill: "#fff",
|
||||
}}
|
||||
/>
|
||||
<linearGradient
|
||||
id="SVGID_3_"
|
||||
x1={-18.456}
|
||||
x2={110.382}
|
||||
y1={252.286}
|
||||
y2={252.286}
|
||||
gradientTransform="rotate(-5.297 -26.409 -856.73)scale(.99997)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop
|
||||
offset={0}
|
||||
style={{
|
||||
stopColor: "#333",
|
||||
stopOpacity: 0,
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.477}
|
||||
style={{
|
||||
stopColor: "#575756",
|
||||
stopOpacity: 0.2,
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.995}
|
||||
style={{
|
||||
stopColor: "#333",
|
||||
stopOpacity: 0.001911551,
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={1}
|
||||
style={{
|
||||
stopColor: "#333",
|
||||
stopOpacity: 0,
|
||||
}}
|
||||
/>
|
||||
</linearGradient>
|
||||
<path
|
||||
d="M81.6 222.4s56.5 68.4 123.9 42.4l6.6-29.7C150 272.2 82.8 214.3 82.8 214.3z"
|
||||
style={{
|
||||
fill: "url(#SVGID_3_)",
|
||||
}}
|
||||
/>
|
||||
<linearGradient
|
||||
id="SVGID_4_"
|
||||
x1={110.198}
|
||||
x2={283.737}
|
||||
y1={262.493}
|
||||
y2={262.493}
|
||||
gradientTransform="matrix(.9991 .04164 -.04164 .9991 -36.323 1.051)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop
|
||||
offset={0}
|
||||
style={{
|
||||
stopColor: "#333",
|
||||
stopOpacity: 0,
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.477}
|
||||
style={{
|
||||
stopColor: "#575756",
|
||||
stopOpacity: 0.2,
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.995}
|
||||
style={{
|
||||
stopColor: "#333",
|
||||
stopOpacity: 0.001911551,
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={1}
|
||||
style={{
|
||||
stopColor: "#333",
|
||||
stopOpacity: 0,
|
||||
}}
|
||||
/>
|
||||
</linearGradient>
|
||||
<path
|
||||
d="M64.2 234.9s63.1 101.5 157.8 78.9l14.2-38.5c-89.5 38.3-168.9-51-168.9-51z"
|
||||
style={{
|
||||
fill: "url(#SVGID_4_)",
|
||||
}}
|
||||
/>
|
||||
<linearGradient
|
||||
id="SVGID_5_"
|
||||
x1={218.967}
|
||||
x2={428.745}
|
||||
y1={263.594}
|
||||
y2={263.594}
|
||||
gradientTransform="rotate(9.265 -38.832 -826.002)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop
|
||||
offset={0}
|
||||
style={{
|
||||
stopColor: "#333",
|
||||
stopOpacity: 0,
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.477}
|
||||
style={{
|
||||
stopColor: "#575756",
|
||||
stopOpacity: 0.2,
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.995}
|
||||
style={{
|
||||
stopColor: "#333",
|
||||
stopOpacity: 0.001911551,
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={1}
|
||||
style={{
|
||||
stopColor: "#333",
|
||||
stopOpacity: 0,
|
||||
}}
|
||||
/>
|
||||
</linearGradient>
|
||||
<path
|
||||
d="M46.6 251.5s61.1 130.9 178 117.5l22.6-44.2c-113 33-195.3-85.7-195.3-85.7z"
|
||||
style={{
|
||||
fill: "url(#SVGID_5_)",
|
||||
}}
|
||||
/>
|
||||
</g>
|
||||
</switch>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function WebhookIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={2500}
|
||||
height={2448}
|
||||
fillRule="evenodd"
|
||||
strokeLinejoin="round"
|
||||
strokeMiterlimit={2}
|
||||
clipRule="evenodd"
|
||||
viewBox="-6.74 -6.74 223.55 218.799"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="#7547db"
|
||||
d="M111.134 71.906a21 21 0 0 1-1.324.042c-11.314 0-20.5-9.186-20.5-20.5s9.186-20.5 20.5-20.5 20.5 9.186 20.5 20.5a20.4 20.4 0 0 1-4.582 12.913l22.66 41.655a51 51 0 0 1 17.422-3.068c28.148 0 51 22.852 51 51s-22.852 51-51 51a51.2 51.2 0 0 1-28.036-8.373 7.303 7.303 0 0 1-1.359-11.105c.015-.016.031-.033.061-.036a7.665 7.665 0 0 1 9.736-1.228 36.06 36.06 0 0 0 19.598 5.792c19.897 0 36.05-16.153 36.05-36.05s-16.153-36.05-36.05-36.05c-6.89 0-16.669 3.014-22.548 6.745a2.637 2.637 0 0 1-3.899-.845zm-9.412 88.614a51 51 0 0 1-6.054 16.62c-14.074 24.378-45.29 32.743-69.667 18.669S-6.74 150.518 7.333 126.14a51.2 51.2 0 0 1 21.27-20.094 7.3 7.3 0 0 1 6.167-.211 7.3 7.3 0 0 1 4.13 4.587c.006.022.012.043 0 .07a7.665 7.665 0 0 1-3.804 9.047 36.05 36.05 0 0 0-14.816 14.076c-9.948 17.23-4.036 39.297 13.195 49.246 17.231 9.948 39.298 4.035 49.246-13.196 3.445-5.967 5.724-15.943 5.433-22.9a2.632 2.632 0 0 1 2.681-2.953l59.054 1.499q.292-.59.626-1.168c5.657-9.798 18.205-13.161 28.003-7.504 9.8 5.658 13.161 18.206 7.504 28.004s-18.205 13.16-28.004 7.503a20.4 20.4 0 0 1-8.892-10.424zM76.97 90.555A51 51 0 0 1 65.601 77c-14.074-24.377-5.71-55.594 18.667-69.667S139.862 1.624 153.935 26a51.2 51.2 0 0 1 6.768 28.466 7.306 7.306 0 0 1-8.938 6.73c-.022-.005-.044-.011-.062-.035a7.664 7.664 0 0 1-5.932-7.817 36.04 36.04 0 0 0-4.783-19.87c-9.948-17.23-32.014-23.143-49.245-13.195S68.599 52.294 78.548 69.525c3.445 5.967 10.945 12.929 17.115 16.155a2.635 2.635 0 0 1 1.218 3.799l-30.826 50.393q.366.548.7 1.125c5.656 9.799 2.294 22.347-7.505 28.004s-22.346 2.295-28.003-7.504-2.295-22.346 7.503-28.003a20.4 20.4 0 0 1 13.474-2.488z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export function MSTeamsIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (<svg xmlns="http://www.w3.org/2000/svg" width={256} height={256} viewBox="0 0 256 239" {...props}>
|
||||
<defs>
|
||||
<path id="logosMicrosoftTeams0"
|
||||
d="M136.93 64.476v12.8a32.674 32.674 0 0 1-5.953-.952a38.698 38.698 0 0 1-26.79-22.742h21.848c6.008.022 10.872 4.887 10.895 10.894Z"
|
||||
opacity=".2"/>
|
||||
</defs>
|
||||
<defs>
|
||||
<linearGradient id="logosMicrosoftTeams1" x1="17.372%" x2="82.628%" y1="-6.51%" y2="106.51%">
|
||||
<stop offset="0%" stop-color="#5A62C3"/>
|
||||
<stop offset="50%" stop-color="#4D55BD"/>
|
||||
<stop offset="100%" stop-color="#3940AB"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill="#5059C9"
|
||||
d="M178.563 89.302h66.125c6.248 0 11.312 5.065 11.312 11.312v60.231c0 22.96-18.613 41.574-41.573 41.574h-.197c-22.96.003-41.576-18.607-41.579-41.568V95.215a5.912 5.912 0 0 1 5.912-5.913Z"/>
|
||||
<circle cx="223.256" cy="50.605" r="26.791" fill="#5059C9"/>
|
||||
<circle cx="139.907" cy="38.698" r="38.698" fill="#7B83EB"/>
|
||||
<path fill="#7B83EB"
|
||||
d="M191.506 89.302H82.355c-6.173.153-11.056 5.276-10.913 11.449v68.697c-.862 37.044 28.445 67.785 65.488 68.692c37.043-.907 66.35-31.648 65.489-68.692v-68.697c.143-6.173-4.74-11.296-10.913-11.449Z"/>
|
||||
<path
|
||||
d="M142.884 89.302v96.268a10.96 10.96 0 0 1-6.787 10.062c-1.3.55-2.697.833-4.108.833H76.68c-.774-1.965-1.488-3.93-2.084-5.953a72.509 72.509 0 0 1-3.155-21.076v-68.703c-.143-6.163 4.732-11.278 10.895-11.43h60.547Z"
|
||||
opacity=".1"/>
|
||||
<path
|
||||
d="M136.93 89.302v102.222c0 1.411-.283 2.808-.833 4.108a10.96 10.96 0 0 1-10.062 6.787H79.48c-1.012-1.965-1.965-3.93-2.798-5.954a59.049 59.049 0 0 1-2.084-5.953a72.508 72.508 0 0 1-3.155-21.076v-68.703c-.143-6.163 4.732-11.278 10.895-11.43h54.593Z"
|
||||
opacity=".2"/>
|
||||
<path
|
||||
d="M136.93 89.302v90.315c-.045 5.998-4.896 10.85-10.895 10.895H74.597a72.508 72.508 0 0 1-3.155-21.076v-68.703c-.143-6.163 4.732-11.278 10.895-11.43h54.593Z"
|
||||
opacity=".2"/>
|
||||
<path
|
||||
d="M130.977 89.302v90.315c-.046 5.998-4.897 10.85-10.895 10.895H74.597a72.508 72.508 0 0 1-3.155-21.076v-68.703c-.143-6.163 4.732-11.278 10.895-11.43h48.64Z"
|
||||
opacity=".2"/>
|
||||
<path
|
||||
d="M142.884 58.523v18.753c-1.012.06-1.965.12-2.977.12c-1.012 0-1.965-.06-2.977-.12a32.674 32.674 0 0 1-5.953-.952a38.698 38.698 0 0 1-26.791-22.742a33.082 33.082 0 0 1-1.905-5.954h29.708c6.007.023 10.872 4.887 10.895 10.895Z"
|
||||
opacity=".1"/>
|
||||
<use href="#logosMicrosoftTeams0" opacity=".2"/>
|
||||
<use href="#logosMicrosoftTeams0" opacity=".2"/>
|
||||
<path d="M130.977 64.476v11.848a38.698 38.698 0 0 1-26.791-22.743h15.896c6.008.023 10.872 4.888 10.895 10.895Z"
|
||||
opacity=".2"/>
|
||||
<path fill="url(#logosMicrosoftTeams1)"
|
||||
d="M10.913 53.581h109.15c6.028 0 10.914 4.886 10.914 10.913v109.151c0 6.027-4.886 10.913-10.913 10.913H10.913C4.886 184.558 0 179.672 0 173.645V64.495C0 58.466 4.886 53.58 10.913 53.58Z"/>
|
||||
<path fill="#FFF" d="M94.208 95.125h-21.82v59.416H58.487V95.125H36.769V83.599h57.439z"/>
|
||||
</svg>)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import {Server} from "lucide-react";
|
||||
import type {SVGProps} from "react";
|
||||
import {ProviderIconTypes} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
|
||||
|
||||
export const storageProviders: ProviderIconTypes[] = [
|
||||
{value: "local", label: "Local", icon: Server},
|
||||
{value: "s3", label: "S3", icon: S3Icon},
|
||||
{value: "google-drive", label: "Google Drive", icon: GoogleDriveIcon},
|
||||
{value: "blob", label: "Azure Blob Storage", icon: BlobIcon, preview: true},
|
||||
{value: "gcs", label: "Google Cloud Storage", icon: GCSIcon, preview: true},
|
||||
]
|
||||
|
||||
export function S3Icon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={256} height={199} {...props} viewBox="0 0 24 24">
|
||||
<path fill="currentColor"
|
||||
d="M13.207.006a2.16 2.16 0 0 0-1.62.582a2.15 2.15 0 0 0-.095 3.035l3.408 3.55a3.042 3.042 0 0 1-.663 4.688l-.463.239V7.285a15.42 15.42 0 0 0-8.018 10.487v.017l6.549-3.328v7.621L13.779 24V13.682l.897-.463a4.443 4.443 0 0 0 1.22-7.03l-3.37-3.525a.75.75 0 0 1 .037-1.055a.75.75 0 0 1 1.056.038l.467.486l-.006.006l4.07 4.244a.057.057 0 0 0 .082 0a.06.06 0 0 0 0-.07l-3.14-5.143l-.149.143l.149-.145C14.494.393 13.829.054 13.207.006m-.902 9.865v2.994l-4.152 2.149a14 14 0 0 1 2.767-3.928a14 14 0 0 1 1.385-1.215"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function GoogleDriveIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={256} height={199} {...props} viewBox="0 0 256 229"><path fill="#0066da" d="m19.354 196.034l11.29 19.5c2.346 4.106 5.718 7.332 9.677 9.678q17.009-21.591 23.68-33.137q6.77-11.717 16.641-36.655q-26.604-3.502-40.32-3.502q-13.165 0-40.322 3.502c0 4.545 1.173 9.09 3.519 13.196z"/><path fill="#ea4335" d="M215.681 225.212c3.96-2.346 7.332-5.572 9.677-9.677l4.692-8.064l22.434-38.855a26.57 26.57 0 0 0 3.518-13.196q-27.315-3.502-40.247-3.502q-13.899 0-40.248 3.502q9.754 25.075 16.422 36.655q6.724 11.683 23.752 33.137"/><path fill="#00832d" d="M128.001 73.311q19.68-23.768 27.125-36.655q5.996-10.377 13.196-33.137C164.363 1.173 159.818 0 155.126 0h-54.25C96.184 0 91.64 1.32 87.68 3.519q9.16 26.103 15.544 37.154q7.056 12.213 24.777 32.638"/><path fill="#2684fc" d="M175.36 155.42H80.642l-40.32 69.792c3.958 2.346 8.503 3.519 13.195 3.519h148.968c4.692 0 9.238-1.32 13.196-3.52z"/><path fill="#00ac47" d="M128.001 73.311L87.681 3.52c-3.96 2.346-7.332 5.571-9.678 9.677L3.519 142.224A26.57 26.57 0 0 0 0 155.42h80.642z"/><path fill="#ffba00" d="m215.242 77.71l-37.243-64.514c-2.345-4.106-5.718-7.331-9.677-9.677l-40.32 69.792l47.358 82.109h80.496c0-4.546-1.173-9.09-3.519-13.196z"/></svg>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export function BlobIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width={256} height={199} {...props} viewBox="0 0 48 48">
|
||||
<path fill="#2979ff"
|
||||
d="M32.66,7H15.34c-0.715,0-1.375,0.381-1.732,1l-8.66,15c-0.357,0.619-0.357,1.381,0,2l8.66,15c0.357,0.619,1.018,1,1.732,1H32.66c0.715,0,1.375-0.381,1.732-1l8.66-15c0.357-0.619,0.357-1.381,0-2l-8.66-15C34.035,7.381,33.375,7,32.66,7z"></path>
|
||||
<path fill="#fff"
|
||||
d="M32,35H16c-0.552,0-1-0.448-1-1V14c0-0.552,0.448-1,1-1h13l4,4v17C33,34.552,32.552,35,32,35z"></path>
|
||||
<path fill="#b1cfff" d="M29,13v3c0,0.552,0.448,1,1,1h3L29,13z"></path>
|
||||
<path fill="#03a9f4"
|
||||
d="M22.265 23.831H20.93V19.58l-1.312.383v-1.011l2.524-.873h.122V23.831zM28.702 21.48c0 .416-.049.776-.146 1.081-.097.303-.234.556-.411.756-.176.2-.387.349-.632.446-.245.097-.515.146-.81.146-.298 0-.57-.049-.816-.146-.246-.097-.457-.246-.633-.446-.176-.2-.315-.453-.413-.756-.099-.305-.147-.665-.147-1.081v-1.051c0-.413.049-.773.146-1.078.097-.305.234-.558.411-.758.176-.2.387-.349.632-.446C26.126 18.049 26.397 18 26.695 18c.295 0 .565.049.811.146.246.097.458.246.635.446.176.2.313.453.412.758.099.305.149.665.149 1.078V21.48zM27.37 20.26c0-.229-.016-.421-.047-.579-.032-.155-.076-.283-.134-.381-.058-.097-.129-.167-.212-.209-.083-.042-.176-.063-.282-.063-.105 0-.2.021-.283.063-.083.042-.153.112-.209.209-.057.097-.1.225-.13.381-.03.158-.045.35-.045.579v1.383c0 .234.014.431.045.589.03.158.075.286.133.381.058.097.129.166.213.208.084.042.179.063.284.063.103 0 .195-.021.278-.063.083-.042.154-.111.212-.208.058-.095.101-.223.132-.381.03-.158.046-.354.046-.589V20.26zM23.308 29.571c0 .416-.049.776-.146 1.081-.097.303-.234.556-.411.756-.176.2-.387.349-.632.446C21.874 31.951 21.604 32 21.309 32c-.298 0-.57-.049-.816-.146-.246-.097-.457-.246-.633-.446-.176-.2-.315-.453-.413-.756-.099-.305-.147-.665-.147-1.081V28.52c0-.413.049-.773.146-1.078.097-.305.234-.558.411-.758.176-.2.387-.349.632-.446.245-.097.516-.146.814-.146.295 0 .565.049.811.146.246.097.458.246.635.446.176.2.313.453.412.758s.149.665.149 1.078V29.571zM21.977 28.35c0-.229-.016-.421-.047-.579-.032-.155-.076-.283-.134-.381-.058-.097-.129-.167-.212-.209-.083-.042-.176-.063-.282-.063s-.2.021-.283.063c-.083.042-.153.112-.209.209-.057.097-.1.225-.13.381-.03.158-.045.35-.045.579v1.383c0 .234.014.431.045.589.03.158.075.286.133.381.058.097.129.166.213.208s.179.063.284.063c.103 0 .195-.021.278-.063.083-.042.154-.111.212-.208.058-.095.101-.223.132-.381.03-.158.046-.354.046-.589V28.35zM27.659 31.921h-1.335V27.67l-1.312.383v-1.011l2.524-.873h.122V31.921z"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function GCSIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
id="standard_product_icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 512 512"
|
||||
{...props}
|
||||
>
|
||||
<g id="bounding_box">
|
||||
<rect width="512" height="512" fill="none"/>
|
||||
</g>
|
||||
<g id="art">
|
||||
<path
|
||||
fill="#34a853"
|
||||
d="M442,277.9H70c-8.8,0-16,7.2-16,16v148.1c0,8.8,7.2,16,16,16h107.2c1.5.5,3.1.7,4.8.7s3.3-.3,4.8-.7h255.2c8.8,0,16-7.2,16-16v-148.1c0-8.8-7.2-16-16-16ZM86,309.9h80v116.1h-80v-116.1ZM426,425.9h-228v-116.1h228v116.1Z"
|
||||
/>
|
||||
<path
|
||||
fill="#fbbc04"
|
||||
d="M442,54H70c-8.8,0-16,7.2-16,16v148.8c0,8.8,7.2,16,16,16h372c8.8,0,16-7.2,16-16V70c0-8.8-7.2-16-16-16ZM86,86h80v116.8h-80v-116.8ZM426,202.8h-228v-116.8h228v116.8Z"
|
||||
/>
|
||||
<path
|
||||
fill="#ea4335"
|
||||
d="M442,234.8h-16V86H54v-16c0-8.8,7.2-16,16-16h372c8.8,0,16,7.2,16,16v148.8c0,8.8-7.2,16-16,16Z"
|
||||
/>
|
||||
<path
|
||||
fill="#4285f4"
|
||||
d="M442,457.9h-16v-148.1H54v-16c0-8.8,7.2-16,16-16h372c8.8,0,16,7.2,16,16v148.1c0,8.8-7.2,16-16,16Z"
|
||||
/>
|
||||
<circle fill="#ea4335" cx="349" cy="144.4" r="37"/>
|
||||
<circle fill="#4285f4" cx="349" cy="367.9" r="37"/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user