mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21b9b8b44d | ||
|
|
6ef7fcc0c2 | ||
|
|
fc4ce178cb | ||
|
|
98b72988c4 | ||
|
|
317473e528 | ||
|
|
e13c5ddf51 | ||
|
|
90be3ab731 | ||
|
|
85c0445a64 | ||
|
|
6a227ccd7c | ||
|
|
3d9a21f854 | ||
|
|
d4229a985d | ||
|
|
c6e0921dea | ||
|
|
346a8a4375 | ||
|
|
4799faa827 | ||
|
|
2c6f5c38ed | ||
|
|
4a884ab5cc | ||
|
|
e59beaf749 | ||
|
|
0d4e1229ab | ||
|
|
8810fd91c9 | ||
|
|
7cf43c5e7c | ||
|
|
77183af08d | ||
|
|
3a2c5be5dd | ||
|
|
11c764c2f3 | ||
|
|
d128234ae6 | ||
|
|
ee31ee225d | ||
|
|
e2195616d3 | ||
|
|
2ca818b996 | ||
|
|
131f2b4538 | ||
|
|
6731c3fea9 | ||
|
|
d3da5e8c13 | ||
|
|
3b0d4225ce | ||
|
|
66f0930164 | ||
|
|
3979b83a0a | ||
|
|
b65e432564 | ||
|
|
340e7c3e00 | ||
|
|
dcdce63e15 | ||
|
|
c2eaed757d | ||
|
|
920743bea7 | ||
|
|
0970f5baae | ||
|
|
291aa13e9a | ||
|
|
e644cde514 | ||
|
|
2350e68f4f | ||
|
|
cce3b681e3 | ||
|
|
755799f19d | ||
|
|
ec02dc8579 | ||
|
|
7028982ba1 | ||
|
|
f04503e38e | ||
|
|
f1edffed17 | ||
|
|
fac87acee7 | ||
|
|
c46dd91ede | ||
|
|
648753f8cf | ||
|
|
76b3bda313 | ||
|
|
8102dd1a8f | ||
|
|
aceb4e7f4c | ||
|
|
4224389e96 | ||
|
|
7c66c10b6d | ||
|
|
bf06a2e50c | ||
|
|
8c69fdef95 | ||
|
|
883c1d2f57 | ||
|
|
e7f1652b4e | ||
|
|
ea07224a52 | ||
|
|
b655154182 | ||
|
|
a173892163 | ||
|
|
14f48c4a35 | ||
|
|
88aa4daf74 | ||
|
|
25326dc9be | ||
|
|
030cd13f7c | ||
|
|
d557620c81 | ||
|
|
3273f8dd11 | ||
|
|
646719557c | ||
|
|
1640b326be | ||
|
|
cc19c9ec19 | ||
|
|
bb84ab9697 | ||
|
|
0f7664201a | ||
|
|
0b8a8213e6 | ||
|
|
476b3da66b | ||
|
|
41c632ccfa | ||
|
|
dbf5addf12 | ||
|
|
8d414080a4 | ||
|
|
c8d390726c | ||
|
|
31285dd0d6 | ||
|
|
b9ef58044e | ||
|
|
e320f74dfc | ||
|
|
b9deb3cdf0 | ||
|
|
5dbf293e1e | ||
|
|
47f2308acc | ||
|
|
496be692e7 | ||
|
|
5df6dd169a | ||
|
|
7232f17cb7 | ||
|
|
5b1c3d0606 | ||
|
|
8e6ca64fa2 | ||
|
|
9116b4d126 | ||
|
|
4ce38d9857 | ||
|
|
8811c48384 | ||
|
|
078d0d1ee2 | ||
|
|
56d755afa6 | ||
|
|
9e3abb366f | ||
|
|
4ebac1c46f | ||
|
|
1476df7a1f | ||
|
|
833872603a | ||
|
|
737834310b | ||
|
|
4474c8ee5e | ||
|
|
ebe5c63a7b | ||
|
|
afd98ae652 | ||
|
|
8f905c8128 | ||
|
|
f43f30c9b9 | ||
|
|
6b1bea9b17 | ||
|
|
cdaed9766e | ||
|
|
d31a9ab2d2 | ||
|
|
07d49749fb | ||
|
|
a1f1da8a07 | ||
|
|
1b791fd287 | ||
|
|
2a4af58a51 | ||
|
|
b12f8ca044 | ||
|
|
519b3abd1d | ||
|
|
e08a5bd6b6 | ||
|
|
f6c597b42d | ||
|
|
f3b9032174 | ||
|
|
44b4ee2953 | ||
|
|
b77f79f42d | ||
|
|
8017bf7c8a | ||
|
|
85f4cda752 | ||
|
|
1fd2f553ed | ||
|
|
d68170eda8 | ||
|
|
51f27226e7 | ||
|
|
d54c366323 | ||
|
|
0e7cb9fbab | ||
|
|
63e83032f1 | ||
|
|
b4da6fdd9f | ||
|
|
12535a59e8 | ||
|
|
b345eda7aa | ||
|
|
660c1dcadf | ||
|
|
be66856034 | ||
|
|
80e3027107 | ||
|
|
3bf731055f | ||
|
|
362ca53efb |
+38
-8
@@ -1,10 +1,10 @@
|
|||||||
# Environnement
|
# Environment
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
|
||||||
# Base de données
|
# Database
|
||||||
DATABASE_URL=postgresql://devuser:changeme@localhost:5433/devdb?schema=public
|
DATABASE_URL=postgresql://devuser:changeme@localhost:5433/devdb?schema=public
|
||||||
|
|
||||||
# Projet
|
# Project
|
||||||
PROJECT_NAME="Portabase"
|
PROJECT_NAME="Portabase"
|
||||||
PROJECT_DESCRIPTION="Portabase is a powerful database manager"
|
PROJECT_DESCRIPTION="Portabase is a powerful database manager"
|
||||||
PROJECT_URL=http://localhost:8887
|
PROJECT_URL=http://localhost:8887
|
||||||
@@ -21,11 +21,41 @@ SMTP_FROM=
|
|||||||
#Port 465 -> secure: true
|
#Port 465 -> secure: true
|
||||||
#By default : true
|
#By default : true
|
||||||
|
|
||||||
# Google
|
# OIDC SSO Authentification
|
||||||
AUTH_GOOGLE_ID=
|
AUTH_OIDC_ID=""
|
||||||
AUTH_GOOGLE_SECRET=
|
AUTH_OIDC_TITLE=""
|
||||||
AUTH_GOOGLE_METHOD=
|
AUTH_OIDC_DESC=""
|
||||||
|
AUTH_OIDC_ICON=""
|
||||||
|
AUTH_OIDC_CLIENT=""
|
||||||
|
AUTH_OIDC_SECRET=""
|
||||||
|
AUTH_OIDC_ISSUER_URL=""
|
||||||
|
AUTH_OIDC_HOST=""
|
||||||
|
AUTH_OIDC_SCOPES=""
|
||||||
|
AUTH_OIDC_DISCOVERY_ENDPOINT=""
|
||||||
|
AUTH_OIDC_JWKS_ENDPOINT=""
|
||||||
|
AUTH_OIDC_PKCE=true
|
||||||
|
ALLOWED_GROUP="admin"
|
||||||
|
AUTH_OIDC_ROLE_MAP=""
|
||||||
|
|
||||||
|
AUTH_ALLOW_LINKING=true
|
||||||
|
AUTH_ALLOW_UNLINKING=true
|
||||||
|
|
||||||
|
|
||||||
|
# Social OAuth2 Authentification
|
||||||
|
AUTH_SOCIAL_ID=""
|
||||||
|
AUTH_SOCIAL_TITLE=""
|
||||||
|
AUTH_SOCIAL_DESC=""
|
||||||
|
AUTH_SOCIAL_ICON=""
|
||||||
|
AUTH_SOCIAL_CLIENT=""
|
||||||
|
AUTH_SOCIAL_SECRET=""
|
||||||
|
AUTH_SOCIAL_APPLE_APP_BUNDLE_IDENTIFIER=""
|
||||||
|
|
||||||
|
# If true, the user's role will be updated from OIDC claims on every login.
|
||||||
|
# If false, the role is only set when the user is first created.
|
||||||
|
AUTH_SYNC_OIDC_ROLES_ON_LOGIN=true
|
||||||
|
AUTH_EMAIL_PASSWORD_ENABLED=true
|
||||||
|
AUTH_SIGNUP_ENABLED=true
|
||||||
|
AUTH_PASSKEY_ENABLED=true
|
||||||
|
|
||||||
# Retention
|
# Retention
|
||||||
RETENTION_CRON="* * * * *"
|
RETENTION_CRON="* * * * *"
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ If you encounter a bug or have a suggestion for improvement, follow these steps:
|
|||||||
|
|
||||||
3. **Push your branch**
|
3. **Push your branch**
|
||||||
```bash
|
```bash
|
||||||
git push origin feature/<feature-name>
|
git push origin feat/<feature-name>
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Open a Pull Request (PR)**
|
4. **Open a Pull Request (PR)**
|
||||||
@@ -82,7 +82,7 @@ If you encounter a bug or have a suggestion for improvement, follow these steps:
|
|||||||
|
|
||||||
## Code Style Guidelines
|
## Code Style Guidelines
|
||||||
|
|
||||||
- Follow the [specific coding style guide] (e.g., Prettier, ESLint, PEP8).
|
- Follow the [specific coding style guide] (e.g., Prettier, ESLint, PEP8,Biome).
|
||||||
- Use meaningful variable names and include comments where necessary.
|
- Use meaningful variable names and include comments where necessary.
|
||||||
- Tests before submitting your changes.
|
- Tests before submitting your changes.
|
||||||
|
|
||||||
@@ -107,4 +107,4 @@ If you encounter a bug or have a suggestion for improvement, follow these steps:
|
|||||||
|
|
||||||
Thank you for contributing! 🙌
|
Thank you for contributing! 🙌
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
name: Auto Release bot
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [closed]
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
if: github.event.pull_request.merged == true
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/create-github-app-token@v1
|
||||||
|
id: app-token
|
||||||
|
with:
|
||||||
|
app-id: ${{ vars.APP_ID }}
|
||||||
|
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||||
|
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ steps.app-token.outputs.token }}
|
||||||
|
ref: ${{ github.base_ref }}
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "lts/*"
|
||||||
|
cache: "pnpm"
|
||||||
|
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- run: git config --global user.name 'github-actions[bot]'
|
||||||
|
- run: git config --global user.email 'github-actions[bot]@users.noreply.github.com'
|
||||||
|
|
||||||
|
- name: Run release-it
|
||||||
|
run: |
|
||||||
|
git pull origin main
|
||||||
|
pnpm exec release-it --ci
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||||
@@ -1,16 +1,8 @@
|
|||||||
name: GitHub Release
|
name: Discord Notification
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
prerelease:
|
|
||||||
required: false
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
make_latest:
|
|
||||||
required: false
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
discord_title:
|
discord_title:
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
@@ -27,74 +19,24 @@ on:
|
|||||||
required: true
|
required: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
create-release:
|
notify-discord:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
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
|
- name: Send Discord Notification
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
RELEASE_INFO=$(gh release view "${{ github.ref_name }}" -R ${{ github.repository }} --json name,url,body,author)
|
RELEASE_INFO=$(gh release view "${{ github.ref_name }}" -R ${{ github.repository }} --json name,url,body,author)
|
||||||
|
|
||||||
RELEASE_TITLE=$(echo "$RELEASE_INFO" | jq -r .name)
|
RELEASE_TITLE=$(echo "$RELEASE_INFO" | jq -r .name)
|
||||||
if [ -z "$RELEASE_TITLE" ] || [ "$RELEASE_TITLE" = "null" ]; then RELEASE_TITLE="${{ github.ref_name }}"; fi
|
if [ -z "$RELEASE_TITLE" ] || [ "$RELEASE_TITLE" = "null" ]; then RELEASE_TITLE="${{ github.ref_name }}"; fi
|
||||||
|
|
||||||
RELEASE_URL=$(echo "$RELEASE_INFO" | jq -r .url)
|
RELEASE_URL=$(echo "$RELEASE_INFO" | jq -r .url)
|
||||||
RELEASE_BODY=$(echo "$RELEASE_INFO" | jq -r .body)
|
RELEASE_BODY=$(echo "$RELEASE_INFO" | jq -r .body)
|
||||||
|
|
||||||
AUTHOR_NAME="Portabase"
|
AUTHOR_NAME="Portabase"
|
||||||
AUTHOR_ICON="https://github.com/Portabase.png"
|
AUTHOR_ICON="https://github.com/Portabase.png"
|
||||||
|
|
||||||
PAYLOAD=$(jq -n \
|
PAYLOAD=$(jq -n \
|
||||||
--arg title "$RELEASE_TITLE" \
|
--arg title "$RELEASE_TITLE" \
|
||||||
@@ -105,7 +47,7 @@ jobs:
|
|||||||
--arg discord_title "${{ inputs.discord_title }}" \
|
--arg discord_title "${{ inputs.discord_title }}" \
|
||||||
--arg discord_footer "${{ inputs.discord_footer }}" \
|
--arg discord_footer "${{ inputs.discord_footer }}" \
|
||||||
--argjson discord_color ${{ inputs.discord_color }} \
|
--argjson discord_color ${{ inputs.discord_color }} \
|
||||||
'{
|
'{
|
||||||
content: $discord_title,
|
content: $discord_title,
|
||||||
embeds: [{
|
embeds: [{
|
||||||
title: $title,
|
title: $title,
|
||||||
@@ -121,8 +63,8 @@ jobs:
|
|||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
}'
|
}'
|
||||||
)
|
)
|
||||||
|
|
||||||
curl -H "Content-Type: application/json" \
|
curl -H "Content-Type: application/json" \
|
||||||
-d "$PAYLOAD" \
|
-d "$PAYLOAD" \
|
||||||
"$DISCORD_WEBHOOK"
|
"$DISCORD_WEBHOOK"
|
||||||
@@ -6,7 +6,7 @@ on:
|
|||||||
image_name:
|
image_name:
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
default: 'portabase/portabase'
|
default: "portabase/portabase"
|
||||||
add_latest:
|
add_latest:
|
||||||
required: false
|
required: false
|
||||||
type: boolean
|
type: boolean
|
||||||
@@ -14,11 +14,11 @@ on:
|
|||||||
target:
|
target:
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
default: 'prod'
|
default: "prod"
|
||||||
dockerfile:
|
dockerfile:
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
default: './docker/dockerfile/Dockerfile'
|
default: "./docker/dockerfile/Dockerfile"
|
||||||
secrets:
|
secrets:
|
||||||
DOCKER_USERNAME:
|
DOCKER_USERNAME:
|
||||||
required: true
|
required: true
|
||||||
@@ -26,34 +26,32 @@ on:
|
|||||||
required: true
|
required: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Build and push Docker images
|
name: Build architectures
|
||||||
runs-on: ${{ matrix.platform == 'linux/amd64' && 'ubuntu-latest' || matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' }}
|
runs-on: ${{ matrix.platform == 'linux/amd64' && 'ubuntu-latest' || 'ubuntu-24.04-arm' }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
platform: [ linux/amd64, linux/arm64 ]
|
platform: [linux/amd64, linux/arm64]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Login to Docker
|
- name: Login to Docker
|
||||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
- name: Set image tag
|
- name: Prepare Image Tags
|
||||||
id: set-tags
|
id: prep
|
||||||
run: |
|
run: |
|
||||||
REF_NAME=${GITHUB_REF#refs/tags/}
|
ARCH=${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
|
||||||
if [ "${{ matrix.platform }}" = "linux/amd64" ]; then
|
echo "image=${{ inputs.image_name }}:${GITHUB_REF#refs/tags/}-$ARCH" >> $GITHUB_OUTPUT
|
||||||
IMAGE="${{ inputs.image_name }}:$REF_NAME-amd64"
|
echo "safe_platform=${{ matrix.platform == 'linux/amd64' && 'linux-amd64' || 'linux-arm64' }}" >> $GITHUB_OUTPUT
|
||||||
else
|
|
||||||
IMAGE="${{ inputs.image_name }}:$REF_NAME-arm64"
|
|
||||||
fi
|
|
||||||
echo "image=$IMAGE" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Build and push image
|
- name: Build and push image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -62,61 +60,55 @@ jobs:
|
|||||||
file: ${{ inputs.dockerfile }}
|
file: ${{ inputs.dockerfile }}
|
||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform }}
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.set-tags.outputs.image }}
|
tags: ${{ steps.prep.outputs.image }}
|
||||||
target: ${{ inputs.target }}
|
target: ${{ inputs.target }}
|
||||||
|
cache-from: type=gha,scope=build-${{ matrix.platform }}
|
||||||
|
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }}
|
||||||
|
|
||||||
- name: Prepare artifact name
|
- name: Save image name for manifest
|
||||||
id: artifact
|
run: echo "${{ steps.prep.outputs.image }}" > image.txt
|
||||||
run: |
|
|
||||||
platform=${{ matrix.platform }}
|
|
||||||
echo "safe_platform=${platform//\//-}" >> $GITHUB_OUTPUT
|
|
||||||
echo "${{ steps.set-tags.outputs.image }}" > image.txt
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: image-${{ steps.artifact.outputs.safe_platform }}
|
name: image-${{ steps.prep.outputs.safe_platform }}
|
||||||
path: image.txt
|
path: image.txt
|
||||||
if-no-files-found: warn
|
retention-days: 1
|
||||||
compression-level: 6
|
|
||||||
overwrite: false
|
|
||||||
include-hidden-files: false
|
|
||||||
|
|
||||||
create-manifest:
|
create-manifest:
|
||||||
name: Create multi-arch Docker manifest (Portabase)
|
name: Create multi-arch manifest
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build
|
needs: build
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
|
- uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: image-linux-amd64
|
name: image-linux-amd64
|
||||||
path: /tmp/digests/amd64
|
path: /tmp/digests/amd64
|
||||||
|
|
||||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
|
- uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: image-linux-arm64
|
name: image-linux-arm64
|
||||||
path: /tmp/digests/arm64
|
path: /tmp/digests/arm64
|
||||||
|
|
||||||
- name: Login to Docker
|
- name: Login to Docker
|
||||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Extract Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ inputs.image_name }}
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
type=raw,value=latest,enable=${{ inputs.add_latest }}
|
||||||
|
|
||||||
- name: Create and push manifest list
|
- name: Create and push manifest list
|
||||||
working-directory: /tmp/digests
|
working-directory: /tmp/digests
|
||||||
run: |
|
run: |
|
||||||
DOCKER_IMAGES="$(cat amd64/image.txt) $(cat arm64/image.txt)"
|
DOCKER_IMAGES="$(cat amd64/image.txt) $(cat arm64/image.txt)"
|
||||||
REF_NAME=${GITHUB_REF#refs/tags/}
|
TAG_ARGS=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON")
|
||||||
MANIFEST_IMAGE="${{ inputs.image_name }}:$REF_NAME"
|
docker buildx imagetools create $DOCKER_IMAGES $TAG_ARGS
|
||||||
|
|
||||||
docker buildx imagetools create $DOCKER_IMAGES -t $MANIFEST_IMAGE
|
|
||||||
docker buildx imagetools inspect $MANIFEST_IMAGE
|
|
||||||
|
|
||||||
if [ "${{ inputs.add_latest }}" = "true" ]; then
|
|
||||||
docker buildx imagetools create $DOCKER_IMAGES -t ${{ inputs.image_name }}:latest
|
|
||||||
docker buildx imagetools inspect ${{ inputs.image_name }}:latest
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
name: PR Checker
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- dev
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate-code:
|
||||||
|
name: Code Integrity Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "lts/*"
|
||||||
|
cache: "pnpm"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Check code formatting
|
||||||
|
run: pnpm run format:check
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Run Linter
|
||||||
|
run: pnpm run lint
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Type Check
|
||||||
|
run: pnpm run typecheck
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Run Tests
|
||||||
|
run: pnpm run test
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Test Application Build
|
||||||
|
run: pnpm run build
|
||||||
@@ -3,29 +3,28 @@ name: Publish Docker image for release candidate
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- '*-rc*'
|
- "*.*.*-rc*"
|
||||||
|
- "!*-*-rc*"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker_publish:
|
docker_publish:
|
||||||
uses: ./.github/workflows/docker.yml
|
uses: ./.github/workflows/docker.yml
|
||||||
with:
|
with:
|
||||||
add_latest: false
|
add_latest: true
|
||||||
secrets:
|
secrets:
|
||||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }}
|
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }}
|
||||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }}
|
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }}
|
||||||
|
|
||||||
github_release:
|
discord_notification:
|
||||||
needs: docker_publish
|
needs: docker_publish
|
||||||
uses: ./.github/workflows/github.yml
|
uses: ./.github/workflows/discord.yml
|
||||||
with:
|
with:
|
||||||
prerelease: true
|
discord_title: "New Release Candidate: ${{ github.ref_name }}"
|
||||||
make_latest: false
|
discord_color: 2044800
|
||||||
discord_title: "||@release-dashboard|| New release candidate published"
|
|
||||||
discord_color: 16776960
|
|
||||||
discord_footer: "Portabase"
|
discord_footer: "Portabase"
|
||||||
secrets:
|
secrets:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ name: Publish Docker image for release
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- '*.*.*'
|
- "*.*.*"
|
||||||
- '!*-*'
|
- "!*-*"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -19,15 +19,13 @@ jobs:
|
|||||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }}
|
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }}
|
||||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }}
|
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }}
|
||||||
|
|
||||||
github_release:
|
discord_notification:
|
||||||
needs: docker_publish
|
needs: docker_publish
|
||||||
uses: ./.github/workflows/github.yml
|
uses: ./.github/workflows/discord.yml
|
||||||
with:
|
with:
|
||||||
prerelease: false
|
discord_title: "New Release: ${{ github.ref_name }}"
|
||||||
make_latest: true
|
discord_color: 3066993
|
||||||
discord_title: "||@release-dashboard|| New release published"
|
|
||||||
discord_color: 5814783
|
|
||||||
discord_footer: "Portabase"
|
discord_footer: "Portabase"
|
||||||
secrets:
|
secrets:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ on:
|
|||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
sca-deps: # Dependency & container scan
|
sca-deps:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -17,15 +17,16 @@ jobs:
|
|||||||
ignore-unfixed: true
|
ignore-unfixed: true
|
||||||
|
|
||||||
|
|
||||||
secrets-gitleaks: # Secrets exposure
|
secrets-gitleaks:
|
||||||
|
if: github.event.pull_request.head.repo.fork == false
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Fetch full history for gitleaks
|
fetch-depth: 0
|
||||||
- uses: gitleaks/gitleaks-action@v2
|
- uses: gitleaks/gitleaks-action@v2
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
|
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
|
||||||
with:
|
with:
|
||||||
config-path: .gitleaks.toml # Optional, if you have a custom config
|
config-path: .gitleaks.toml
|
||||||
+3
-1
@@ -44,4 +44,6 @@ private/keys/*
|
|||||||
|
|
||||||
/.env
|
/.env
|
||||||
|
|
||||||
certificates
|
seeds/pocket-id/*.db
|
||||||
|
|
||||||
|
certificates
|
||||||
|
|||||||
+1
-3
@@ -2,9 +2,7 @@ title = "Custom gitleaks config"
|
|||||||
|
|
||||||
[allowlist]
|
[allowlist]
|
||||||
# Global allowlist patterns (won’t be flagged)
|
# Global allowlist patterns (won’t be flagged)
|
||||||
regexes = [
|
regexes = []
|
||||||
"s3SecretAccessKey: env.S3_SECRET_KEY ?? null",
|
|
||||||
]
|
|
||||||
paths = [
|
paths = [
|
||||||
"src/utils/init.ts"
|
"src/utils/init.ts"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"git": {
|
||||||
|
"commit": true,
|
||||||
|
"commitMessage": "chore: release v${version}",
|
||||||
|
"requireCleanWorkingDir": true,
|
||||||
|
"tag": true,
|
||||||
|
"tagName": "${version}",
|
||||||
|
"push": true
|
||||||
|
},
|
||||||
|
"github": {
|
||||||
|
"release": true,
|
||||||
|
"tokenRef": "GITHUB_TOKEN"
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"@release-it/conventional-changelog": {
|
||||||
|
"preset": {
|
||||||
|
"name": "conventionalcommits",
|
||||||
|
"types": [
|
||||||
|
{
|
||||||
|
"type": "feat",
|
||||||
|
"section": "✨ Features"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "fix",
|
||||||
|
"section": "🐛 Bug Fixes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "perf",
|
||||||
|
"section": "⚡️ Performance Improvements"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "revert",
|
||||||
|
"section": "⏪️ Reverts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "docs",
|
||||||
|
"section": "📝 Documentation",
|
||||||
|
"hidden": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "chore",
|
||||||
|
"section": "🔧 Chores",
|
||||||
|
"hidden": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@release-it/bumper": {
|
||||||
|
"out": {
|
||||||
|
"file": "CITATION.cff",
|
||||||
|
"path": "version",
|
||||||
|
"type": "text/yaml"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-4
@@ -1,6 +1,6 @@
|
|||||||
cff-version: 1.2.0
|
cff-version: 1.2.0
|
||||||
title: Portabase
|
title: Portabase
|
||||||
message: "If you use this software, please cite it as below."
|
message: If you use this software, please cite it as below.
|
||||||
type: software
|
type: software
|
||||||
authors:
|
authors:
|
||||||
- family-names: Gauthereau
|
- family-names: Gauthereau
|
||||||
@@ -9,7 +9,12 @@ authors:
|
|||||||
given-names: Killian
|
given-names: Killian
|
||||||
repository-code: https://github.com/Portabase/portabase
|
repository-code: https://github.com/Portabase/portabase
|
||||||
url: https://portabase.io
|
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."
|
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:
|
keywords:
|
||||||
- docker
|
- docker
|
||||||
- kubernetes
|
- kubernetes
|
||||||
@@ -26,5 +31,5 @@ keywords:
|
|||||||
- web-ui
|
- web-ui
|
||||||
- agent
|
- agent
|
||||||
license: Apache-2.0
|
license: Apache-2.0
|
||||||
version: 1.2.5
|
version: 1.4.13
|
||||||
date-released: "2026-02-13"
|
date-released: '2026-03-02'
|
||||||
|
|||||||
@@ -1,4 +1,48 @@
|
|||||||
CLUSTER_SCRIPT=docker/entrypoints/app-dev-entrypoint.sh
|
CLUSTER_SCRIPT=docker/entrypoints/app-dev-entrypoint.sh
|
||||||
|
|
||||||
up:
|
up:
|
||||||
@bash $(CLUSTER_SCRIPT)
|
@bash $(CLUSTER_SCRIPT)
|
||||||
|
|
||||||
|
seed-keycloak:
|
||||||
|
@[ $$(ls -1 seeds/keycloak/*.json 2>/dev/null | wc -l) -gt 0 ] || (echo "No realm export found in seeds/keycloak. Add an export from Keycloak first."; exit 1)
|
||||||
|
@docker compose -f docker-compose.func.yml stop keycloak >/dev/null 2>&1 || true
|
||||||
|
@docker compose -f docker-compose.func.yml rm -f -s keycloak >/dev/null 2>&1 || true
|
||||||
|
@docker volume rm portabase-dev-func_keycloak-data >/dev/null 2>&1 || true
|
||||||
|
@docker compose -f docker-compose.func.yml up -d keycloak
|
||||||
|
@echo "Keycloak seed import triggered from seeds/keycloak/*.json"
|
||||||
|
|
||||||
|
seed-pocket:
|
||||||
|
@[ -f seeds/pocket-id/portabase.zip ] || (echo "No export found in seeds/pocket-id. Add an export from Pocket ID first."; exit 1)
|
||||||
|
@docker compose -f docker-compose.func.yml stop pocket-id >/dev/null 2>&1 || true
|
||||||
|
@docker compose -f docker-compose.func.yml rm -f -s pocket-id >/dev/null 2>&1 || true
|
||||||
|
@docker volume rm portabase-dev-func_pocket-id-data >/dev/null 2>&1 || true
|
||||||
|
@docker compose -f docker-compose.func.yml run --rm -v ./seeds/pocket-id/portabase.zip:/tmp/portabase.zip pocket-id ./pocket-id import --yes --path /tmp/portabase.zip >/dev/null
|
||||||
|
@docker compose -f docker-compose.func.yml up -d pocket-id
|
||||||
|
@sleep 2
|
||||||
|
@docker compose -f docker-compose.func.yml exec pocket-id ./pocket-id one-time-access-token admin
|
||||||
|
@echo "Pocket ID data restored from seeds/pocket-id/portabase.zip"
|
||||||
|
|
||||||
|
seed-auth: seed-keycloak seed-pocket
|
||||||
|
|
||||||
|
pocket-token:
|
||||||
|
@docker compose -f docker-compose.func.yml exec pocket-id ./pocket-id one-time-access-token admin
|
||||||
|
|
||||||
|
export-pocket:
|
||||||
|
@echo "Exporting Pocket ID data to seeds/pocket-id/portabase.zip..."
|
||||||
|
@mkdir -p ./seeds/pocket-id
|
||||||
|
@docker compose -f docker-compose.func.yml exec pocket-id ./pocket-id export --path /tmp/portabase-export.zip
|
||||||
|
@docker compose -f docker-compose.func.yml cp pocket-id:/tmp/portabase-export.zip ./seeds/pocket-id/portabase.zip
|
||||||
|
@docker compose -f docker-compose.func.yml exec pocket-id rm /tmp/portabase-export.zip
|
||||||
|
@echo "Pocket ID data exported to seeds/pocket-id/portabase.zip"
|
||||||
|
|
||||||
|
export-keycloak:
|
||||||
|
@echo "Exporting Keycloak configuration and users to seeds/keycloak/*.json..."
|
||||||
|
@mkdir -p ./seeds/keycloak
|
||||||
|
@rm -f ./seeds/keycloak/*.json
|
||||||
|
@docker compose -f docker-compose.func.yml stop keycloak >/dev/null 2>&1
|
||||||
|
@docker rm -f kc-exporter >/dev/null 2>&1 || true
|
||||||
|
@docker compose -f docker-compose.func.yml run --name kc-exporter keycloak export --dir /tmp/kc-export --users realm_file
|
||||||
|
@docker cp kc-exporter:/tmp/kc-export/. ./seeds/keycloak/
|
||||||
|
@docker rm -f kc-exporter >/dev/null 2>&1
|
||||||
|
@docker compose -f docker-compose.func.yml start keycloak >/dev/null 2>&1
|
||||||
|
@echo "Keycloak configuration and users exported to seeds/keycloak/*.json"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<h3 align="center">Portabase</h3>
|
<h3 align="center">Portabase</h3>
|
||||||
|
|
||||||
<p align="center" style="margin-top: 20px; font-style: italic;">
|
<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>
|
<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-rust">Portabase agents</a> for managing operations securely and efficiently.</i>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
@@ -19,11 +19,11 @@
|
|||||||
[](https://www.postgresql.org/)
|
[](https://www.postgresql.org/)
|
||||||
[](https://www.mysql.com/)
|
[](https://www.mysql.com/)
|
||||||
[](https://mariadb.org/)
|
[](https://mariadb.org/)
|
||||||
|
[](https://sqlite.org/)
|
||||||
[](https://www.mongodb.com/)
|
[](https://www.mongodb.com/)
|
||||||
[](https://github.com/Portabase/portabase)
|
[](https://github.com/Portabase/portabase)
|
||||||
[](https://github.com/Portabase/portabase)
|
[](https://github.com/Portabase/portabase)
|
||||||
|
|
||||||
|
|
||||||
[![NextJS][NextJS]][NextJS-url]
|
[![NextJS][NextJS]][NextJS-url]
|
||||||
[![BetterAuth][BetterAuth]][BetterAuth-url]
|
[![BetterAuth][BetterAuth]][BetterAuth-url]
|
||||||
[![Drizzle][Drizzle]][Drizzle-url]
|
[![Drizzle][Drizzle]][Drizzle-url]
|
||||||
@@ -34,14 +34,14 @@
|
|||||||
<strong>
|
<strong>
|
||||||
<a href="https://portabase.io">Website</a> •
|
<a href="https://portabase.io">Website</a> •
|
||||||
<a href="https://portabase.io/docs">Documentation</a> •
|
<a href="https://portabase.io/docs">Documentation</a> •
|
||||||
<a href="https://www.youtube.com/watch?v=hvLbX5LN1UE">Demo</a> •
|
<a href="https://www.youtube.com/watch?v=nSTzT27GgAg">Demo</a> •
|
||||||
<a href="https://portabase.io/docs/dashboard/setup">Installation</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=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>
|
<a href="https://github.com/Portabase/portabase/issues/new?labels=enhancement&template=feature-request---.md">Request Feature</a>
|
||||||
</strong>
|
</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -51,14 +51,13 @@
|
|||||||
You have 4 ways to install Portabase:
|
You have 4 ways to install Portabase:
|
||||||
|
|
||||||
- Automated CLI (recommended) - [details](https://portabase.io/docs/dashboard/setup#cli)
|
- Automated CLI (recommended) - [details](https://portabase.io/docs/dashboard/setup#cli)
|
||||||
- Docker Compose setup - [details](https://portabase.io/docs/dashboard/setup#docker)
|
- Docker Run - [details](https://portabase.io/docs/dashboard/setup#docker)
|
||||||
|
- Docker Compose setup - [details](https://portabase.io/docs/dashboard/setup#docker-compose)
|
||||||
- Kubernetes with Helm (soon)
|
- Kubernetes with Helm (soon)
|
||||||
- Development setup - [details](https://portabase.io/docs/dashboard/setup#development)
|
- Development setup - [details](https://portabase.io/docs/dashboard/setup#development)
|
||||||
|
|
||||||
**Ensure Docker is installed on your machine before getting started.**
|
**Ensure Docker is installed on your machine before getting started.**
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Contributors
|
## Contributors
|
||||||
|
|
||||||
[](https://github.com/Portabase/portabase/graphs/contributors)
|
[](https://github.com/Portabase/portabase/graphs/contributors)
|
||||||
@@ -89,4 +88,3 @@ Distributed under the Apache License. See `LICENSE.txt` for more details.
|
|||||||
[ShadcnUI-url]: https://ui.shadcn.com/
|
[ShadcnUI-url]: https://ui.shadcn.com/
|
||||||
|
|
||||||
[Docker-url]: https://www.docker.com/
|
[Docker-url]: https://www.docker.com/
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import {CardContent, CardHeader} from "@/components/ui/card";
|
import { CardContent, CardHeader } from "@/components/ui/card";
|
||||||
|
|
||||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import {ForgotPasswordForm} from "@/components/wrappers/auth/login/forgot-password-form/forgot-password-form";
|
import { ForgotPasswordForm } from "@/components/wrappers/auth/login/forgot-password-form/forgot-password-form";
|
||||||
import {CardAuth} from "@/features/layout/card-auth";
|
import { env } from "@/env.mjs";
|
||||||
|
import { CardAuth } from "@/features/layout/card-auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default async function RoutePage(props: { searchParams: Promise<{ callbackUrl: string | undefined }> }) {
|
export default async function RoutePage(props: { searchParams: Promise<{ callbackUrl: string | undefined }> }) {
|
||||||
|
if (env.AUTH_EMAIL_PASSWORD_ENABLED !== "true") {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@@ -12,12 +17,11 @@ export default async function RoutePage(props: { searchParams: Promise<{ callbac
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="grid gap-2 text-center mb-2">
|
<div className="grid gap-2 text-center mb-2">
|
||||||
<h1 className="text-3xl font-bold">Reset password</h1>
|
<h1 className="text-3xl font-bold">Reset password</h1>
|
||||||
<p className="text-balance text-muted-foreground">Enter your email address and we'll send you a
|
<p className="text-balance text-muted-foreground">Enter your email address and we'll send you a link to reset your password.</p>
|
||||||
link to reset your password.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ForgotPasswordForm/>
|
<ForgotPasswordForm />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</CardAuth>
|
</CardAuth>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
+34
-28
@@ -1,32 +1,38 @@
|
|||||||
import React from "react";
|
import React, { Suspense } from "react";
|
||||||
import {redirect} from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import {currentUser} from "@/lib/auth/current-user";
|
import { currentUser } from "@/lib/auth/current-user";
|
||||||
import {AuthLogoSection} from "@/components/wrappers/auth/auth-logo-section";
|
import { AuthLogoSection } from "@/components/wrappers/auth/auth-logo-section";
|
||||||
import {env} from "@/env.mjs";
|
import { env } from "@/env.mjs";
|
||||||
import {Heart} from "lucide-react";
|
import { Heart } from "lucide-react";
|
||||||
|
import { ErrorLayout } from "@/components/wrappers/common/error-layout";
|
||||||
|
|
||||||
export default async function Layout({children}: { children: React.ReactNode }) {
|
export default async function Layout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const user = await currentUser();
|
||||||
|
|
||||||
const user = await currentUser();
|
if (user && !user.banned && user.role !== "pending") {
|
||||||
|
redirect("/dashboard/home");
|
||||||
|
}
|
||||||
|
|
||||||
if (user && !user.banned && user.role !== "pending") {
|
return (
|
||||||
redirect("/dashboard/home");
|
<div className="flex min-h-screen flex-col justify-between py-10 sm:px-6 lg:px-8 ">
|
||||||
}
|
<div className="flex flex-col items-center justify-center flex-1">
|
||||||
|
<div className="mx-auto w-full max-w-md">
|
||||||
return (
|
<AuthLogoSection />
|
||||||
<div className="flex min-h-screen flex-col justify-between py-10 sm:px-6 lg:px-8 ">
|
<div className="mt-4">{children}</div>
|
||||||
<div className="flex flex-col items-center justify-center flex-1">
|
|
||||||
<div className="mx-auto w-full max-w-md">
|
|
||||||
<AuthLogoSection/>
|
|
||||||
<div className="mt-4">{children}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<footer className="mt-8 text-center text-xs text-muted-foreground flex flex-col gap-1">
|
|
||||||
<p className="flex items-center justify-center gap-1">
|
|
||||||
Made with <Heart className="size-3 fill-red-500 text-red-500" /> by <span className="font-medium text-foreground">Portabase</span>
|
|
||||||
</p>
|
|
||||||
<p>v{env.NEXT_PUBLIC_PROJECT_VERSION}</p>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
}
|
<footer className="mt-8 text-center text-xs text-muted-foreground flex flex-col gap-1">
|
||||||
|
<p className="flex items-center justify-center gap-1">
|
||||||
|
Made with{" "}
|
||||||
|
<Heart className="size-3 fill-red-500 text-red-500 animate-pulse" />{" "}
|
||||||
|
by <span className="font-medium text-foreground">Portabase</span>
|
||||||
|
</p>
|
||||||
|
<p>v{env.NEXT_PUBLIC_PROJECT_VERSION}</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+50
-42
@@ -1,52 +1,60 @@
|
|||||||
import {LoginForm} from "@/components/wrappers/auth/login/login-form/login-form";
|
import { LoginForm } from "@/components/wrappers/auth/login/login-form/login-form";
|
||||||
import {Metadata} from "next";
|
import { Metadata } from "next";
|
||||||
import {SUPPORTED_PROVIDERS} from "../../../portabase.config";
|
import { SUPPORTED_PROVIDERS } from "@/lib/auth/config";
|
||||||
import {SocialAuthButtons} from "@/components/wrappers/auth/social-buttons";
|
import { SocialAuthButtons } from "@/components/wrappers/auth/social-buttons";
|
||||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import {CardContent, CardHeader} from "@/components/ui/card";
|
import { CardContent, CardHeader } from "@/components/ui/card";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {Separator} from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import {CardAuth} from "@/features/layout/card-auth";
|
import { CardAuth } from "@/features/layout/card-auth";
|
||||||
|
import { env } from "@/env.mjs";
|
||||||
|
import { en } from "zod/v4/locales";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Login",
|
title: "Login",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function SignInPage() {
|
export default async function SignInPage() {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<CardAuth className="w-full">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="grid gap-2 text-center mb-2">
|
||||||
|
<h1 className="text-3xl font-bold">Login</h1>
|
||||||
|
<p className="text-balance text-muted-foreground">
|
||||||
|
Fill your login informations
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{env.AUTH_EMAIL_PASSWORD_ENABLED === "true" && (
|
||||||
|
<LoginForm isPasskeyEnabled={env.AUTH_PASSKEY_ENABLED === "true"} />
|
||||||
|
)}
|
||||||
|
|
||||||
return (
|
{env.AUTH_EMAIL_PASSWORD_ENABLED === "true" &&
|
||||||
<TooltipProvider>
|
SUPPORTED_PROVIDERS.filter((p) => !p.isManual && p.isActive)
|
||||||
<CardAuth className="w-full">
|
.length > 0 && (
|
||||||
<CardHeader>
|
<div className="relative my-4 flex items-center justify-center overflow-hidden">
|
||||||
<div className="grid gap-2 text-center mb-2">
|
<Separator />
|
||||||
<h1 className="text-3xl font-bold">Login</h1>
|
<div className="px-2 text-center text-sm">OR</div>
|
||||||
<p className="text-balance text-muted-foreground">Fill your login informations</p>
|
<Separator />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
)}
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<LoginForm/>
|
|
||||||
|
|
||||||
{SUPPORTED_PROVIDERS.filter((p) => !p.isManual && p.isActive).length > 0 && (
|
{SUPPORTED_PROVIDERS.filter((p) => !p.isManual && p.isActive).length >
|
||||||
<>
|
0 && <SocialAuthButtons providers={SUPPORTED_PROVIDERS} />}
|
||||||
<div className="relative my-4 flex items-center justify-center overflow-hidden">
|
|
||||||
<Separator/>
|
|
||||||
<div className="px-2 text-center text-sm">OR</div>
|
|
||||||
<Separator/>
|
|
||||||
</div>
|
|
||||||
<SocialAuthButtons providers={SUPPORTED_PROVIDERS}/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-4 text-center text-sm">
|
{env.AUTH_SIGNUP_ENABLED !== "true" ||
|
||||||
Don't have an account ?{" "}
|
(env.AUTH_EMAIL_PASSWORD_ENABLED === "true" && (
|
||||||
<Link href="/register" className="underline">
|
<div className="mt-4 text-center text-sm">
|
||||||
Sign up
|
Don't have an account ?{" "}
|
||||||
</Link>
|
<Link href="/register" className="underline">
|
||||||
</div>
|
Sign up
|
||||||
</CardContent>
|
</Link>
|
||||||
</CardAuth>
|
</div>
|
||||||
</TooltipProvider>
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</CardAuth>
|
||||||
)
|
</TooltipProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
import {PageParams} from "@/types/next";
|
import { PageParams } from "@/types/next";
|
||||||
import {RegisterForm} from "@/components/wrappers/auth/register/register-form/register-form";
|
import { RegisterForm } from "@/components/wrappers/auth/register/register-form/register-form";
|
||||||
import {Metadata} from "next";
|
import { Metadata } from "next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { env } from "@/env.mjs";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Register",
|
title: "Register",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{}>) {
|
export default async function RoutePage(props: PageParams<{}>) {
|
||||||
return (
|
if (
|
||||||
<div className="mx-auto grid w-full gap-6">
|
env.AUTH_SIGNUP_ENABLED !== "true" ||
|
||||||
<RegisterForm/>
|
env.AUTH_EMAIL_PASSWORD_ENABLED !== "true"
|
||||||
</div>
|
) {
|
||||||
)
|
redirect("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto grid w-full gap-6">
|
||||||
|
<RegisterForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,9 +4,13 @@ import { ResetPasswordForm } from "@/components/wrappers/auth/login/reset-passwo
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { auth } from "@/lib/auth/auth";
|
import { auth } from "@/lib/auth/auth";
|
||||||
import { Avatar, AvatarImage, AvatarFallback } from "@radix-ui/react-avatar";
|
import { Avatar, AvatarImage, AvatarFallback } from "@radix-ui/react-avatar";
|
||||||
import {CardAuth} from "@/features/layout/card-auth";
|
import { CardAuth } from "@/features/layout/card-auth";
|
||||||
|
import { env } from "@/env.mjs";
|
||||||
|
|
||||||
export default async function RoutePage(props: { searchParams: Promise<{ token: string | undefined }> }) {
|
export default async function RoutePage(props: { searchParams: Promise<{ token: string | undefined }> }) {
|
||||||
|
if (env.AUTH_EMAIL_PASSWORD_ENABLED !== "true") {
|
||||||
|
return redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
const { token } = await props.searchParams;
|
const { token } = await props.searchParams;
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
import {PageParams} from "@/types/next";
|
|
||||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
|
||||||
import {db} from "@/db";
|
|
||||||
import {SettingsEmailSection} from "@/components/wrappers/dashboard/admin/settings/email/settings-email-section";
|
|
||||||
import {notFound} from "next/navigation";
|
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{}>) {
|
|
||||||
|
|
||||||
const settings = await db.query.setting.findFirst({
|
|
||||||
where: (fields, {eq}) => eq(fields.name, "system"),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!settings) {
|
|
||||||
notFound()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page>
|
|
||||||
<PageHeader className="flex flex-col">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<PageTitle className="mb-3">System email</PageTitle>
|
|
||||||
</div>
|
|
||||||
</PageHeader>
|
|
||||||
<PageContent className="flex flex-col gap-5">
|
|
||||||
<SettingsEmailSection settings={settings}/>
|
|
||||||
</PageContent>
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import {PageParams} from "@/types/next";
|
|
||||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
|
||||||
import {SettingsStorageSection} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage-section";
|
|
||||||
import {db} from "@/db";
|
|
||||||
import {notFound} from "next/navigation";
|
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{}>) {
|
|
||||||
|
|
||||||
const settings = await db.query.setting.findFirst({
|
|
||||||
where: (fields, {eq}) => eq(fields.name, "system"),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!settings) {
|
|
||||||
notFound()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page>
|
|
||||||
<PageHeader className="flex flex-col">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<PageTitle className="mb-3">System storage</PageTitle>
|
|
||||||
</div>
|
|
||||||
</PageHeader>
|
|
||||||
<PageContent className="flex flex-col gap-5">
|
|
||||||
<SettingsStorageSection settings={settings}/>
|
|
||||||
</PageContent>
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@ import {db} from "@/db";
|
|||||||
import {desc, isNull} from "drizzle-orm";
|
import {desc, isNull} from "drizzle-orm";
|
||||||
import {AdminUserList} from "@/components/wrappers/dashboard/admin/users/admin-user-list";
|
import {AdminUserList} from "@/components/wrappers/dashboard/admin/users/admin-user-list";
|
||||||
import {AdminUserAddModal} from "@/components/wrappers/dashboard/admin/users/admin-user-add-modal";
|
import {AdminUserAddModal} from "@/components/wrappers/dashboard/admin/users/admin-user-add-modal";
|
||||||
|
import {SUPPORTED_PROVIDERS} from "@/lib/auth/config";
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{}>) {
|
export default async function RoutePage(props: PageParams<{}>) {
|
||||||
|
|
||||||
@@ -21,6 +22,9 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const credentialProvider = SUPPORTED_PROVIDERS.find(p => p.id === 'credential');
|
||||||
|
const isPasswordAuthEnabled = credentialProvider?.isActive || false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<PageHeader className="flex flex-col">
|
<PageHeader className="flex flex-col">
|
||||||
@@ -32,7 +36,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
</div>
|
</div>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<PageContent className="flex flex-col gap-5">
|
<PageContent className="flex flex-col gap-5">
|
||||||
<AdminUserList users={users}/>
|
<AdminUserList users={users} isPasswordAuthEnabled={isPasswordAuthEnabled}/>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,61 +1,69 @@
|
|||||||
import {PageParams} from "@/types/next";
|
import { PageParams } from "@/types/next";
|
||||||
import {Page, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
import {
|
||||||
import {db} from "@/db";
|
Page,
|
||||||
|
PageContent,
|
||||||
|
PageDescription,
|
||||||
|
PageTitle,
|
||||||
|
} from "@/features/layout/page";
|
||||||
|
import { db } from "@/db";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {eq} from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import {notFound} from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import {ButtonDeleteAgent} from "@/components/wrappers/dashboard/agent/button-delete-agent/button-delete-agent";
|
import { ButtonDeleteAgent } from "@/components/wrappers/dashboard/agent/button-delete-agent/button-delete-agent";
|
||||||
import {capitalizeFirstLetter} from "@/utils/text";
|
import { capitalizeFirstLetter } from "@/utils/text";
|
||||||
import {generateEdgeKey} from "@/utils/edge_key";
|
import { generateEdgeKey } from "@/utils/edge_key";
|
||||||
import {getServerUrl} from "@/utils/get-server-url";
|
import { getServerUrl } from "@/utils/get-server-url";
|
||||||
import {AgentContentPage} from "@/components/wrappers/dashboard/agent/agent-content";
|
import { AgentContentPage } from "@/components/wrappers/dashboard/agent/agent-content";
|
||||||
import {AgentDialog} from "@/features/agents/components/agent.dialog";
|
import { AgentDialog } from "@/features/agents/components/agent.dialog";
|
||||||
import {AgentType} from "@/features/agents/agents.schema";
|
import { AgentType } from "@/features/agents/agents.schema";
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{ agentId: string }>) {
|
export default async function RoutePage(
|
||||||
|
props: PageParams<{ agentId: string }>,
|
||||||
|
) {
|
||||||
|
const { agentId } = await props.params;
|
||||||
|
|
||||||
const {agentId} = await props.params
|
const agent = await db.query.agent.findFirst({
|
||||||
|
where: eq(drizzleDb.schemas.agent.id, agentId),
|
||||||
|
with: {
|
||||||
|
databases: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const agent = await db.query.agent.findFirst({
|
if (!agent) {
|
||||||
where: eq(drizzleDb.schemas.agent.id, agentId),
|
notFound();
|
||||||
with: {
|
}
|
||||||
databases: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!agent) {
|
const edgeKey = await generateEdgeKey(getServerUrl(), agent.id);
|
||||||
notFound()
|
|
||||||
}
|
|
||||||
|
|
||||||
const edgeKey = await generateEdgeKey(getServerUrl(), agent.id);
|
return (
|
||||||
|
<Page>
|
||||||
return (
|
<div className="justify-between gap-2 sm:flex">
|
||||||
<Page>
|
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||||
<div className="justify-between gap-2 sm:flex">
|
<div className="min-w-full md:min-w-fit ">
|
||||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
{capitalizeFirstLetter(agent.name)}
|
||||||
<div className="min-w-full md:min-w-fit ">
|
</div>
|
||||||
{capitalizeFirstLetter(agent.name)}
|
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
<AgentDialog
|
||||||
<div className="flex items-center gap-2">
|
agent={agent as AgentType & { id: string }}
|
||||||
<AgentDialog agent={agent as AgentType & { id: string }} typeTrigger={"edit"}/>
|
typeTrigger={"edit"}
|
||||||
</div>
|
/>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"}/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageTitle>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageTitle>
|
||||||
|
</div>
|
||||||
|
|
||||||
{agent.description && (
|
{agent.description && (
|
||||||
<PageDescription className="mt-5 sm:mt-0">{agent.description}</PageDescription>
|
<PageDescription className="mt-5 sm:mt-0">
|
||||||
)}
|
{agent.description}
|
||||||
<PageContent className="flex flex-col w-full h-full justify-between gap-6">
|
</PageDescription>
|
||||||
<AgentContentPage
|
)}
|
||||||
agent={agent}
|
<PageContent className="flex flex-col w-full h-full justify-between gap-6">
|
||||||
edgeKey={edgeKey}
|
<AgentContentPage agent={agent} edgeKey={edgeKey} />
|
||||||
/>
|
</PageContent>
|
||||||
</PageContent>
|
</Page>
|
||||||
</Page>
|
);
|
||||||
)
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -35,12 +35,10 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
<PageHeader>
|
<PageHeader>
|
||||||
<PageTitle>Notification channels</PageTitle>
|
<PageTitle>Notification channels</PageTitle>
|
||||||
<PageActions>
|
<PageActions>
|
||||||
{/*<NotifierAddEditModal adminView={false}/>*/}
|
|
||||||
<ChannelAddEditModal kind="notification" adminView={false}/>
|
<ChannelAddEditModal kind="notification" adminView={false}/>
|
||||||
</PageActions>
|
</PageActions>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<PageContent>
|
<PageContent>
|
||||||
{/*<NotificationChannelsSection organizations={organizations} notificationChannels={notificationChannels}/>*/}
|
|
||||||
<ChannelsSection kind="notification" organizations={organizations}
|
<ChannelsSection kind="notification" organizations={organizations}
|
||||||
channels={notificationChannels}/>
|
channels={notificationChannels}/>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
|
|||||||
-36
@@ -1,36 +0,0 @@
|
|||||||
import { PageParams } from "@/types/next";
|
|
||||||
import { Page, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
|
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
import { DatabaseForm } from "@/components/wrappers/dashboard/database/database-form/database-form";
|
|
||||||
|
|
||||||
import { db } from "@/db";
|
|
||||||
import * as drizzleDb from "@/db";
|
|
||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{ databaseId: string }>) {
|
|
||||||
const { databaseId } = await props.params;
|
|
||||||
|
|
||||||
const dbItem = await db.query.database.findFirst({
|
|
||||||
where: eq(drizzleDb.schemas.database.id, databaseId),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!dbItem) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page>
|
|
||||||
<PageHeader>
|
|
||||||
<PageTitle>Edit {dbItem.name}</PageTitle>
|
|
||||||
</PageHeader>
|
|
||||||
<PageContent>
|
|
||||||
<DatabaseForm
|
|
||||||
databaseId={databaseId}
|
|
||||||
// @ts-ignore
|
|
||||||
defaultValues={{ ...dbItem, dbms: dbItem.dbms ?? "inactive", description: dbItem.description ?? undefined }}
|
|
||||||
/>
|
|
||||||
</PageContent>
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,89 +1,95 @@
|
|||||||
import {PageParams} from "@/types/next";
|
import { PageParams } from "@/types/next";
|
||||||
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
import {
|
||||||
import {currentUser} from "@/lib/auth/current-user";
|
Page,
|
||||||
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
PageActions,
|
||||||
import {notFound} from "next/navigation";
|
PageContent,
|
||||||
import {Metadata} from "next";
|
PageHeader,
|
||||||
import {OrganizationTabs} from "@/components/wrappers/dashboard/organization/tabs/organization-tabs";
|
PageTitle,
|
||||||
import {getOrganizationChannels} from "@/db/services/notification-channel";
|
} from "@/features/layout/page";
|
||||||
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
|
import { currentUser } from "@/lib/auth/current-user";
|
||||||
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
|
import { getActiveMember, getOrganization } from "@/lib/auth/auth";
|
||||||
import {DeleteOrganizationButton} from "@/components/wrappers/dashboard/organization/delete-organization-button";
|
import { notFound } from "next/navigation";
|
||||||
import {EditOrganizationDialog} from "@/features/organization/components/edit-organization.dialog";
|
import { Metadata } from "next";
|
||||||
import {Button} from "@/components/ui/button";
|
import { OrganizationTabs } from "@/components/wrappers/dashboard/organization/tabs/organization-tabs";
|
||||||
import {GearIcon} from "@radix-ui/react-icons";
|
import { getOrganizationChannels } from "@/db/services/notification-channel";
|
||||||
import {db} from "@/db";
|
import { computeOrganizationPermissions } from "@/lib/acl/organization-acl";
|
||||||
import {isNull} from "drizzle-orm";
|
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 { db } from "@/db";
|
||||||
|
import { isNull } from "drizzle-orm";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {eq} from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
||||||
const organization = await getOrganization({});
|
const organization = await getOrganization({});
|
||||||
const user = await currentUser();
|
const user = await currentUser();
|
||||||
const activeMember = await getActiveMember()
|
const activeMember = await getActiveMember();
|
||||||
|
|
||||||
if (!organization || !activeMember || !user) {
|
if (!organization || !activeMember || !user) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const notificationChannels = await getOrganizationChannels(organization.id)
|
const notificationChannels = await getOrganizationChannels(organization.id);
|
||||||
const storageChannels = await getOrganizationStorageChannels(organization.id)
|
const storageChannels = await getOrganizationStorageChannels(organization.id);
|
||||||
const permissions = computeOrganizationPermissions(activeMember);
|
const permissions = computeOrganizationPermissions(activeMember);
|
||||||
|
|
||||||
const users = await db.query.user.findMany({
|
const users = await db.query.user.findMany({
|
||||||
where: (fields) => isNull(fields.deletedAt)
|
where: (fields) => isNull(fields.deletedAt),
|
||||||
});
|
});
|
||||||
|
|
||||||
const organizationWithMembers = await db.query.organization.findFirst({
|
const organizationWithMembers = await db.query.organization.findFirst({
|
||||||
where: eq(drizzleDb.schemas.organization.id, organization.id),
|
where: eq(drizzleDb.schemas.organization.id, organization.id),
|
||||||
|
with: {
|
||||||
|
members: {
|
||||||
with: {
|
with: {
|
||||||
members: {
|
user: true,
|
||||||
with: {
|
},
|
||||||
user: true
|
},
|
||||||
}
|
},
|
||||||
}
|
});
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!organizationWithMembers) notFound();
|
if (!organizationWithMembers) notFound();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<PageHeader>
|
<PageHeader>
|
||||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||||
<div className="min-w-full md:min-w-fit ">
|
<div className="min-w-full md:min-w-fit ">Organization settings</div>
|
||||||
Organization settings
|
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
{permissions.canManageSettings &&
|
||||||
<div className="flex items-center gap-2">
|
organization.slug !== "default" && (
|
||||||
{permissions.canManageSettings && organization.slug !== "default" && (
|
<EditOrganizationDialog
|
||||||
<EditOrganizationDialog
|
organization={organizationWithMembers}
|
||||||
organization={organizationWithMembers}
|
users={users}
|
||||||
users={users}
|
currentUser={user}
|
||||||
currentUser={user}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
{permissions.canManageDangerZone &&
|
||||||
{permissions.canManageDangerZone && organization.slug !== "default" && (
|
organization.slug !== "default" && (
|
||||||
<DeleteOrganizationButton organizationSlug={organization.slug}/>
|
<DeleteOrganizationButton
|
||||||
)}
|
organizationSlug={organization.slug}
|
||||||
</div>
|
/>
|
||||||
</div>
|
)}
|
||||||
</PageTitle>
|
</div>
|
||||||
</PageHeader>
|
</div>
|
||||||
<PageContent>
|
</PageTitle>
|
||||||
<OrganizationTabs
|
</PageHeader>
|
||||||
activeMember={activeMember}
|
<PageContent>
|
||||||
organization={organization}
|
<OrganizationTabs
|
||||||
notificationChannels={notificationChannels}
|
activeMember={activeMember}
|
||||||
storageChannels={storageChannels}
|
organization={organization}
|
||||||
/>
|
notificationChannels={notificationChannels}
|
||||||
</PageContent>
|
storageChannels={storageChannels}
|
||||||
</Page>
|
/>
|
||||||
)
|
</PageContent>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,26 @@
|
|||||||
import React, {ReactNode} from "react";
|
import { ReactNode } from "react";
|
||||||
import {redirect} from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import {SidebarInset, SidebarProvider} from "@/components/ui/sidebar";
|
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||||
import {AppSidebar} from "@/components/wrappers/dashboard/common/sidebar/app-sidebar";
|
import { AppSidebar } from "@/components/wrappers/dashboard/common/sidebar/app-sidebar";
|
||||||
import {Header} from "@/features/layout/Header";
|
import { Header } from "@/features/layout/Header";
|
||||||
import {currentUser} from "@/lib/auth/current-user";
|
import { currentUser } from "@/lib/auth/current-user";
|
||||||
import {ThemeMetaUpdater} from "@/features/browser/theme-meta-updater";
|
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 }) {
|
export default async function Layout({ children }: { children: ReactNode }) {
|
||||||
const user = await currentUser();
|
const user = await currentUser();
|
||||||
if (!user) redirect("/login");
|
if (!user) redirect("/login");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<div className="flex flex-col lg:flex-row w-full">
|
<div className="flex flex-col lg:flex-row w-full">
|
||||||
<ThemeMetaUpdater/>
|
<ThemeMetaUpdater />
|
||||||
<AppSidebar/>
|
<AppSidebar />
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
<Header/>
|
<Header />
|
||||||
<main className="h-full">{children}</main>
|
<main className="h-full">{children}</main>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</div>
|
</div>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
|
|||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
const body: BodyPatch = await request.json();
|
const body: BodyPatch = await request.json();
|
||||||
|
console.log("body", body);
|
||||||
|
|
||||||
const status = body.status
|
const status = body.status
|
||||||
const backupId = body.backupId
|
const backupId = body.backupId
|
||||||
@@ -131,7 +132,7 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
|
|||||||
|
|
||||||
|
|
||||||
eventEmitter.emit('modification', {update: true});
|
eventEmitter.emit('modification', {update: true});
|
||||||
await sendNotificationsBackupRestore(database, `${status}_backup` as EventKind);
|
await sendNotificationsBackupRestore(database, status == "failed" ? "error_backup" : "success_backup" as EventKind);
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -63,8 +63,7 @@ export async function POST(
|
|||||||
.set({status: body.status as RestorationStatus})
|
.set({status: body.status as RestorationStatus})
|
||||||
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
||||||
|
|
||||||
|
await sendNotificationsBackupRestore(database, body.status == "failed" ? "error_restore" : "success_restore");
|
||||||
await sendNotificationsBackupRestore(database, body.status == "error" ? "error_restore" : "success_restore");
|
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
message: true,
|
message: true,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {NextResponse} from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
PROJECT_URL: process.env.PROJECT_URL,
|
PROJECT_URL: process.env.PROJECT_URL,
|
||||||
PROJECT_NAME: process.env.PROJECT_NAME,
|
PROJECT_NAME: process.env.PROJECT_NAME,
|
||||||
PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION,
|
PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {NextResponse} from "next/server";
|
import {NextResponse} from "next/server";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import {env} from "@/env.mjs";
|
||||||
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
@@ -29,7 +30,8 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const uploadDir = path.join(process.cwd(), "/private/uploads/");
|
// const uploadDir = path.join(process.cwd(), "/private/uploads/");
|
||||||
|
const uploadDir = path.join(env.PRIVATE_PATH, "/uploads/");
|
||||||
|
|
||||||
const oldFilePath = path.join(uploadDir, "tmp", id);
|
const oldFilePath = path.join(uploadDir, "tmp", id);
|
||||||
const newFilePath = path.join(uploadDir, filePath);
|
const newFilePath = path.join(uploadDir, filePath);
|
||||||
|
|||||||
+30
-82
@@ -1,85 +1,33 @@
|
|||||||
// "use client"
|
"use client";
|
||||||
//
|
|
||||||
// import {useSearchParams} from "next/navigation"
|
|
||||||
//
|
|
||||||
// enum Error {
|
|
||||||
// Configuration = "Configuration",
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// const errorMap = {
|
|
||||||
// [Error.Configuration]: (
|
|
||||||
// <p>
|
|
||||||
// There was a problem when trying to authenticate. Please contact us if this
|
|
||||||
// error persists. Unique error code:{" "}
|
|
||||||
// <code className="rounded-sm bg-slate-100 p-1 text-xs">Configuration</code>
|
|
||||||
// </p>
|
|
||||||
// ),
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// export default function AuthErrorPage() {
|
|
||||||
// const search = useSearchParams()
|
|
||||||
// const error = search.get("error") as Error
|
|
||||||
//
|
|
||||||
// return (
|
|
||||||
// <div className="flex h-screen w-full flex-col items-center justify-center">
|
|
||||||
// <a
|
|
||||||
// href="#"
|
|
||||||
// className="block max-w-sm rounded-lg border border-gray-200 bg-white p-6 text-center shadow-sm hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700"
|
|
||||||
// >
|
|
||||||
// <h5 className="mb-2 flex flex-row items-center justify-center gap-2 text-xl font-bold tracking-tight text-gray-900 dark:text-white">
|
|
||||||
// Something went wrong
|
|
||||||
// </h5>
|
|
||||||
// <div className="font-normal text-gray-700 dark:text-gray-400">
|
|
||||||
// {errorMap[error] || "Please contact us if this error persists."}
|
|
||||||
// </div>
|
|
||||||
// </a>
|
|
||||||
// </div>
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
"use client"
|
|
||||||
|
|
||||||
import { useSearchParams } from "next/navigation"
|
import { Loader2 } from "lucide-react";
|
||||||
import React, { Suspense } from "react"
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useSession } from "@/lib/auth/auth-client";
|
||||||
|
|
||||||
enum Error {
|
export default function ErrorPage() {
|
||||||
Configuration = "Configuration",
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { data: session, isPending } = useSession();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = searchParams.get("error");
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (error) {
|
||||||
|
params.set("error", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const destination = session ? "/dashboard/home" : "/login";
|
||||||
|
router.replace(`${destination}?${params.toString()}`);
|
||||||
|
}, [isPending, session, router, searchParams]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-full items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const errorMap = {
|
|
||||||
[Error.Configuration]: (
|
|
||||||
<p>
|
|
||||||
There was a problem when trying to authenticate. Please contact us if this
|
|
||||||
error persists. Unique error code:{" "}
|
|
||||||
<code className="rounded-sm bg-slate-100 p-1 text-xs">Configuration</code>
|
|
||||||
</p>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
function AuthErrorPageContent() {
|
|
||||||
const search = useSearchParams()
|
|
||||||
const error = search.get("error") as Error
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-screen w-full flex-col items-center justify-center">
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="block max-w-sm rounded-lg border border-gray-200 bg-white p-6 text-center shadow-sm hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
<h5 className="mb-2 flex flex-row items-center justify-center gap-2 text-xl font-bold tracking-tight text-gray-900 dark:text-white">
|
|
||||||
Something went wrong
|
|
||||||
</h5>
|
|
||||||
<div className="font-normal text-gray-700 dark:text-gray-400">
|
|
||||||
{errorMap[error] || "Please contact us if this error persists."}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AuthErrorPage() {
|
|
||||||
return (
|
|
||||||
<Suspense fallback={<div>Loading...</div>}>
|
|
||||||
<AuthErrorPageContent />
|
|
||||||
</Suspense>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+32
-28
@@ -1,38 +1,42 @@
|
|||||||
import React from "react";
|
import type { Metadata } from "next";
|
||||||
import type {Metadata} from "next";
|
import type React from "react";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import {Providers} from "./providers";
|
import { ConsoleSilencer } from "@/components/wrappers/common/console-silencer";
|
||||||
import {cn} from "@/lib/utils";
|
import { author, geistMono, poppins } from "@/fonts/fonts";
|
||||||
import {ConsoleSilencer} from "@/components/wrappers/common/console-silencer";
|
import { cn } from "@/lib/utils";
|
||||||
import {author, geistMono, poppins} from "@/fonts/fonts";
|
import { Providers } from "./providers";
|
||||||
|
|
||||||
const title = process.env.PROJECT_NAME ?? "App Title";
|
const title = process.env.PROJECT_NAME ?? "App Title";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: {
|
title: {
|
||||||
default: title,
|
default: title,
|
||||||
template: `%s - ${title}`
|
template: `%s - ${title}`,
|
||||||
},
|
},
|
||||||
description: process.env.PROJECT_DESCRIPTION ?? undefined,
|
description: process.env.PROJECT_DESCRIPTION ?? undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
|
return (
|
||||||
return (
|
<html lang="en" suppressHydrationWarning>
|
||||||
<html lang="en" suppressHydrationWarning>
|
<head>
|
||||||
<head>
|
<meta name="apple-mobile-web-app-title" content={title} />
|
||||||
<meta name="apple-mobile-web-app-title" content={title}/>
|
</head>
|
||||||
</head>
|
<body
|
||||||
<body className={cn(poppins.variable, author.variable, geistMono.variable, "font-sans h-full")}>
|
className={cn(
|
||||||
<ConsoleSilencer/>
|
poppins.variable,
|
||||||
<Providers>
|
author.variable,
|
||||||
{children}
|
geistMono.variable,
|
||||||
</Providers>
|
"font-sans h-full",
|
||||||
</body>
|
)}
|
||||||
</html>
|
>
|
||||||
);
|
<ConsoleSilencer />
|
||||||
|
<Providers>{children}</Providers>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-10
@@ -1,15 +1,19 @@
|
|||||||
import BackButton from "@/components/wrappers/common/button/back-button";
|
import BackButton from "@/components/wrappers/common/button/back-button";
|
||||||
|
|
||||||
export default async function NotFound() {
|
export default async function NotFound() {
|
||||||
return(
|
return (
|
||||||
<div className="flex items-center min-h-screen px-4 py-12 sm:px-6 md:px-8 lg:px-12 xl:px-16">
|
<div className="flex items-center min-h-screen px-4 py-12 sm:px-6 md:px-8 lg:px-12 xl:px-16">
|
||||||
<div className="w-full space-y-6 text-center">
|
<div className="w-full space-y-6 text-center">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">Not found</h1>
|
<h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">
|
||||||
<p className="leading-7 [&:not(:first-child)]:mt-6">The content you are trying to view is not available.</p>
|
Not found
|
||||||
</div>
|
</h1>
|
||||||
<BackButton>Go home</BackButton>
|
<p className="leading-7 not-first:mt-6">
|
||||||
</div>
|
The content you are trying to view is not available.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
<BackButton>Go home</BackButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-21
@@ -1,30 +1,28 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {PropsWithChildren, Suspense} from "react";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import {ThemeProvider} from "@/features/theme/theme-provider";
|
import { type PropsWithChildren, Suspense } from "react";
|
||||||
|
|
||||||
import {Toaster} from "@/components/ui/sonner";
|
|
||||||
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
|
|
||||||
import {ThemeMetaUpdaterRoot} from "@/features/browser/theme-meta-updater-root";
|
|
||||||
|
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { ErrorLayout } from "@/components/wrappers/common/error-layout";
|
||||||
|
import { ThemeMetaUpdaterRoot } from "@/features/browser/theme-meta-updater-root";
|
||||||
|
import { ThemeProvider } from "@/features/theme/theme-provider";
|
||||||
|
|
||||||
export type ProviderProps = PropsWithChildren<{}>;
|
export type ProviderProps = PropsWithChildren<{}>;
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
export const Providers = (props: ProviderProps) => {
|
export const Providers = (props: ProviderProps) => {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<ThemeProvider
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
attribute="class"
|
<ThemeMetaUpdaterRoot />
|
||||||
defaultTheme="system"
|
<QueryClientProvider client={queryClient}>
|
||||||
enableSystem
|
<ErrorLayout>
|
||||||
>
|
<Toaster />
|
||||||
<ThemeMetaUpdaterRoot/>
|
{props.children}
|
||||||
<QueryClientProvider client={queryClient}>
|
</ErrorLayout>
|
||||||
<Toaster/>
|
</QueryClientProvider>
|
||||||
{props.children}
|
</ThemeProvider>
|
||||||
</QueryClientProvider>
|
</Suspense>
|
||||||
</ThemeProvider>
|
);
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
name: portabase-dev-func
|
||||||
|
|
||||||
|
services:
|
||||||
|
keycloak:
|
||||||
|
image: quay.io/keycloak/keycloak:latest
|
||||||
|
command: start-dev --import-realm
|
||||||
|
# environment:
|
||||||
|
# KC_BOOTSTRAP_ADMIN_USERNAME: admin
|
||||||
|
# KC_BOOTSTRAP_ADMIN_PASSWORD: admin
|
||||||
|
ports:
|
||||||
|
- "3056:8080"
|
||||||
|
volumes:
|
||||||
|
- keycloak-data:/opt/keycloak/data
|
||||||
|
- ./seeds/keycloak:/opt/keycloak/data/import:ro
|
||||||
|
- ./export:/tmp/export
|
||||||
|
pocket-id:
|
||||||
|
image: ghcr.io/pocket-id/pocket-id
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- APP_URL=http://localhost:3055
|
||||||
|
- ENCRYPTION_KEY=QwHyjbZvSsDUAcjpdmSPsuYxaH6vET6OeBaeLwXccCb43L6Om3W1AoU5pKIJTzYr
|
||||||
|
ports:
|
||||||
|
- 3055:1411
|
||||||
|
volumes:
|
||||||
|
- pocket-id-data:/app/data
|
||||||
|
- ./seeds/pocket-id:/seed:ro
|
||||||
|
healthcheck:
|
||||||
|
test: "curl -f http://localhost:1411/healthz"
|
||||||
|
interval: 1m30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 2
|
||||||
|
start_period: 10s
|
||||||
|
volumes:
|
||||||
|
keycloak-data:
|
||||||
|
pocket-id-data:
|
||||||
@@ -1,19 +1,18 @@
|
|||||||
name: portabase-prod
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
app:
|
app:
|
||||||
build:
|
# build:
|
||||||
context: .
|
# context: .
|
||||||
dockerfile: docker/dockerfile/Dockerfile
|
# dockerfile: docker/dockerfile/Dockerfile
|
||||||
target: prod
|
# target: prod
|
||||||
image: portabase/portabase:1.2.5-rc.9
|
image: portabase/portabase:1.4.0
|
||||||
ports:
|
ports:
|
||||||
- '8887:80'
|
- '8887:80'
|
||||||
environment:
|
environment:
|
||||||
TZ: "Europe/Paris"
|
TZ: "Europe/Paris"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
volumes:
|
||||||
|
- portabase-data:/data
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -36,4 +35,5 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
portabase-data:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
|
|||||||
+2
-1
@@ -12,7 +12,7 @@ services:
|
|||||||
- POSTGRES_USER=devuser
|
- POSTGRES_USER=devuser
|
||||||
- POSTGRES_PASSWORD=changeme
|
- POSTGRES_PASSWORD=changeme
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [ "CMD-SHELL", "pg_isready -U devuser -d devdb" ]
|
test: ["CMD-SHELL", "pg_isready -U devuser -d devdb"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -30,6 +30,7 @@ services:
|
|||||||
- "localhost:host-gateway"
|
- "localhost:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
- ./private/uploads/tmp:/data/uploads/tmp
|
- ./private/uploads/tmp:/data/uploads/tmp
|
||||||
|
user: "1000:1000"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
#FROM node:22-bullseye AS base
|
|
||||||
FROM --platform=$BUILDPLATFORM node:22-bullseye AS base
|
FROM --platform=$BUILDPLATFORM node:22-bullseye AS base
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
@@ -18,6 +17,21 @@ RUN apt-get update && apt-get install -y \
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|
||||||
|
RUN mkdir -p /etc/apt/keyrings \
|
||||||
|
&& curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
|
||||||
|
| gpg --dearmor -o /etc/apt/keyrings/postgresql.gpg \
|
||||||
|
&& echo "deb [signed-by=/etc/apt/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt bullseye-pgdg main" \
|
||||||
|
> /etc/apt/sources.list.d/pgdg.list
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
postgresql-18 \
|
||||||
|
postgresql-client-18 \
|
||||||
|
postgresql-contrib-18 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
ENV PATH="/usr/lib/postgresql/18/bin:${PATH}"
|
||||||
|
|
||||||
|
|
||||||
FROM --platform=$BUILDPLATFORM golang:1.23-bookworm AS tusd-base
|
FROM --platform=$BUILDPLATFORM golang:1.23-bookworm AS tusd-base
|
||||||
|
|
||||||
ARG TUSD_VERSION=2.8.0
|
ARG TUSD_VERSION=2.8.0
|
||||||
@@ -76,6 +90,9 @@ ENV NODE_ENV=production
|
|||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
ENV PORT=80
|
ENV PORT=80
|
||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
ENV PGDATA=/data/postgres
|
||||||
|
ENV PRIVATE_PATH=/data/private
|
||||||
|
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser --system --uid 1001 nextjs
|
||||||
@@ -95,13 +112,13 @@ RUN chmod +x /usr/local/bin/tusd
|
|||||||
COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf
|
COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|
||||||
|
|
||||||
RUN mkdir -p .next /app/private/uploads \
|
RUN mkdir -p .next /data/private/uploads \
|
||||||
&& chown -R nextjs:nodejs .next /app/private /app/public
|
&& chown -R nextjs:nodejs .next /data/private /app/public
|
||||||
|
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
COPY ./docker/entrypoints/app-prod-entrypoint.sh /app/app-prod-entrypoint.sh
|
COPY ./docker/entrypoints/app-prod-entrypoint.sh /app/app-prod-entrypoint.sh
|
||||||
RUN chmod +x /app/app-prod-entrypoint.sh
|
RUN chmod +x /app/app-prod-entrypoint.sh
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
#USER nextjs
|
|
||||||
ENTRYPOINT ["sh","/app/app-prod-entrypoint.sh"]
|
ENTRYPOINT ["sh","/app/app-prod-entrypoint.sh"]
|
||||||
|
|||||||
@@ -7,9 +7,72 @@ else
|
|||||||
echo "[WARN] No TZ provided, using default container timezone"
|
echo "[WARN] No TZ provided, using default container timezone"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p /app/private/uploads/tmp
|
POSTGRES_BIN=$(ls -d /usr/lib/postgresql/*/bin | head -n 1)
|
||||||
|
|
||||||
|
if [ -z "$POSTGRES_BIN" ]; then
|
||||||
|
echo "PostgreSQL binaries not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export PATH="$POSTGRES_BIN:$PATH"
|
||||||
|
|
||||||
|
if [ -z "$DATABASE_URL" ]; then
|
||||||
|
echo "[INFO] No DATABASE_URL provided, starting internal Postgres..."
|
||||||
|
|
||||||
|
mkdir -p "$PGDATA"
|
||||||
|
chown -R postgres:postgres "$PGDATA"
|
||||||
|
|
||||||
|
if [ ! -f "$PGDATA/PG_VERSION" ]; then
|
||||||
|
echo "[INFO] Initializing database cluster..."
|
||||||
|
if ! su postgres -c "initdb -D '$PGDATA'" > /dev/null 2>&1; then
|
||||||
|
echo "[ERROR] initdb failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! su postgres -c "pg_ctl -D '$PGDATA' \
|
||||||
|
-o \"-c listen_addresses='localhost' -c logging_collector=on\" \
|
||||||
|
-l $PGDATA/postgres.log -w start" > /dev/null 2>&1; then
|
||||||
|
echo "[ERROR] PostgreSQL failed to start"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
until su postgres -c "pg_isready -h 127.0.0.1 -p 5432" > /dev/null 2>&1; do
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[INFO] PostgreSQL server is up and accepting connections"
|
||||||
|
|
||||||
|
DB_USER="${POSTGRES_USER:-portabase_user}"
|
||||||
|
DB_PASS="${POSTGRES_PASSWORD:-JaB6b1SUtIWYvt7srnOt}"
|
||||||
|
DB_NAME="${POSTGRES_DB:-portabase_db}"
|
||||||
|
|
||||||
|
USER_EXISTS=$(su postgres -c "psql -tAc \"SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'\"" 2>/dev/null)
|
||||||
|
if [ "$USER_EXISTS" != "1" ]; then
|
||||||
|
if ! su postgres -c "psql -c \"CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';\"" > /dev/null 2>&1; then
|
||||||
|
echo "[ERROR] Failed creating user"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
DB_EXISTS=$(su postgres -c "psql -tAc \"SELECT 1 FROM pg_database WHERE datname='$DB_NAME'\"" 2>/dev/null)
|
||||||
|
if [ "$DB_EXISTS" != "1" ]; then
|
||||||
|
if ! su postgres -c "psql -c \"CREATE DATABASE $DB_NAME OWNER $DB_USER;\"" > /dev/null 2>&1; then
|
||||||
|
echo "[ERROR] Failed creating database"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
export DATABASE_URL="postgres://$DB_USER:$DB_PASS@127.0.0.1:5432/$DB_NAME"
|
||||||
|
|
||||||
|
echo "[SUCCESS] Internal PostgreSQL started successfully"
|
||||||
|
echo "[SUCCESS] Database: $DB_NAME | User: $DB_USER | Host: 127.0.0.1:5432"
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
mkdir -p /data/private/uploads/tmp
|
||||||
echo "▶ Starting tusd server..."
|
echo "▶ Starting tusd server..."
|
||||||
tusd --base-path /tus/files/ --upload-dir /app/private/uploads/tmp --hooks-http http://127.0.0.1:3000/api/tus/hooks --port 1080 --max-size 21474836480 &
|
tusd --base-path /tus/files/ --upload-dir /data/private/uploads/tmp --hooks-http http://127.0.0.1:3000/api/tus/hooks --port 1080 --max-size 21474836480 &
|
||||||
|
|
||||||
echo "▶ Starting Next.js server..."
|
echo "▶ Starting Next.js server..."
|
||||||
PORT=3000 node server.js &
|
PORT=3000 node server.js &
|
||||||
|
|||||||
+20
-15
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "portabase",
|
"name": "portabase",
|
||||||
"version": "1.2.5",
|
"version": "1.4.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack -p 8887",
|
"dev": "next dev --turbopack -p 8887",
|
||||||
@@ -11,9 +11,12 @@
|
|||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:drop": "drizzle-kit drop",
|
"db:drop": "drizzle-kit drop",
|
||||||
"auth:generate": "npx @better-auth/cli generate --config ./src/lib/auth/auth.ts"
|
"auth:generate": "npx @better-auth/cli generate --config ./src/lib/auth/auth.ts",
|
||||||
|
"release": "release-it"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@better-auth/passkey": "^1.4.19",
|
||||||
|
"@better-auth/sso": "^1.4.19",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
@@ -48,12 +51,12 @@
|
|||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/nodemailer": "^6.4.22",
|
"@types/nodemailer": "^6.4.23",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"@zenstackhq/runtime": "2.14.2",
|
"@zenstackhq/runtime": "2.14.2",
|
||||||
"argon2": "^0.43.1",
|
"argon2": "^0.43.1",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"better-auth": "1.4.5",
|
"better-auth": "1.4.18",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
@@ -63,12 +66,11 @@
|
|||||||
"drizzle-orm": "^0.43.1",
|
"drizzle-orm": "^0.43.1",
|
||||||
"drizzle-zod": "^0.7.1",
|
"drizzle-zod": "^0.7.1",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"express": "^5.2.1",
|
|
||||||
"googleapis": "^170.1.0",
|
"googleapis": "^170.1.0",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.553.0",
|
"lucide-react": "^0.553.0",
|
||||||
"minio": "^8.0.6",
|
"minio": "^8.0.6",
|
||||||
"motion": "^12.34.0",
|
"motion": "^12.34.3",
|
||||||
"next": "16.1.5",
|
"next": "16.1.5",
|
||||||
"next-safe-action": "^7.10.8",
|
"next-safe-action": "^7.10.8",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
@@ -83,7 +85,7 @@
|
|||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-dropzone": "^14.4.1",
|
"react-dropzone": "^14.4.1",
|
||||||
"react-email": "^4.3.2",
|
"react-email": "^4.3.2",
|
||||||
"react-hook-form": "^7.71.1",
|
"react-hook-form": "^7.71.2",
|
||||||
"react-qr-code": "^2.0.18",
|
"react-qr-code": "^2.0.18",
|
||||||
"react-resizable-panels": "^3.0.6",
|
"react-resizable-panels": "^3.0.6",
|
||||||
"react-twc": "^1.5.1",
|
"react-twc": "^1.5.1",
|
||||||
@@ -93,8 +95,8 @@
|
|||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"swiper": "^12.1.0",
|
"swiper": "^12.1.2",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"ws": "^8.19.0",
|
"ws": "^8.19.0",
|
||||||
@@ -104,7 +106,9 @@
|
|||||||
"@iconify/react": "^6.0.2",
|
"@iconify/react": "^6.0.2",
|
||||||
"@react-email/preview-server": "4.3.2",
|
"@react-email/preview-server": "4.3.2",
|
||||||
"@react-email/render": "^2.0.4",
|
"@react-email/render": "^2.0.4",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@release-it/bumper": "^7.0.5",
|
||||||
|
"@release-it/conventional-changelog": "^10.0.5",
|
||||||
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
||||||
"@types/node": "^22.19.11",
|
"@types/node": "^22.19.11",
|
||||||
"@types/node-forge": "^1.3.14",
|
"@types/node-forge": "^1.3.14",
|
||||||
@@ -113,19 +117,20 @@
|
|||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@zenstackhq/openapi": "^2.22.1",
|
"@zenstackhq/openapi": "^2.22.1",
|
||||||
"@zenstackhq/tanstack-query": "^2.22.2",
|
"@zenstackhq/tanstack-query": "^2.22.2",
|
||||||
"baseline-browser-mapping": "^2.9.19",
|
"baseline-browser-mapping": "^2.10.0",
|
||||||
"drizzle-kit": "^0.31.9",
|
"drizzle-kit": "^0.31.9",
|
||||||
"esbuild": "^0.27.3",
|
"esbuild": "^0.27.3",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.3",
|
||||||
"eslint-config-next": "^16.1.6",
|
"eslint-config-next": "^16.1.6",
|
||||||
"eslint-plugin-tailwindcss": "^3.18.2",
|
"eslint-plugin-tailwindcss": "^3.18.2",
|
||||||
"framer-motion": "^12.34.0",
|
"framer-motion": "^12.34.3",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^4.1.18",
|
"release-it": "^19.2.4",
|
||||||
|
"tailwindcss": "^4.2.1",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"zenstack": "2.14.2"
|
"zenstack": "2.14.2"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.29.2+sha512.bef43fa759d91fd2da4b319a5a0d13ef7a45bb985a3d7342058470f9d2051a3ba8674e629672654686ef9443ad13a82da2beb9eeb3e0221c87b8154fff9d74b8"
|
"packageManager": "pnpm@10.30.1+sha512.3590e550d5384caa39bd5c7c739f72270234b2f6059e13018f975c313b1eb9fefcc09714048765d4d9efe961382c312e624572c0420762bdc5d5940cdf9be73a"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2429
-802
File diff suppressed because it is too large
Load Diff
+1
-45
@@ -1,17 +1,5 @@
|
|||||||
|
|
||||||
|
|
||||||
export interface AuthProviderConfig {
|
|
||||||
id: "google" | "github" | "credential";
|
|
||||||
isActive: boolean;
|
|
||||||
icon: string;
|
|
||||||
isManual?: boolean;
|
|
||||||
credentials?: {
|
|
||||||
clientId: string;
|
|
||||||
clientSecret: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const PORTABASE_DEFAULT_SETTINGS = {
|
export const PORTABASE_DEFAULT_SETTINGS = {
|
||||||
SECURITY: {
|
SECURITY: {
|
||||||
CSP: {
|
CSP: {
|
||||||
@@ -64,7 +52,7 @@ export const PORTABASE_DEFAULT_SETTINGS = {
|
|||||||
FORM_ACTION: ["'self'"],
|
FORM_ACTION: ["'self'"],
|
||||||
FRAME_ANCESTORS: ["'none'"],
|
FRAME_ANCESTORS: ["'none'"],
|
||||||
BLOCK_ALL_MIXED_CONTENT: false,
|
BLOCK_ALL_MIXED_CONTENT: false,
|
||||||
UPGRADE_INSECURE_REQUESTS: true,
|
UPGRADE_INSECURE_REQUESTS: process.env.PROJECT_URL?.startsWith("https://") || false,
|
||||||
},
|
},
|
||||||
PERMISSIONS_POLICY: {
|
PERMISSIONS_POLICY: {
|
||||||
CAMERA: ["()"],
|
CAMERA: ["()"],
|
||||||
@@ -76,35 +64,3 @@ export const PORTABASE_DEFAULT_SETTINGS = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export const SUPPORTED_PROVIDERS: AuthProviderConfig[] = [
|
|
||||||
{
|
|
||||||
id: "google",
|
|
||||||
icon: "hugeicons:chrome",
|
|
||||||
isActive: Boolean(process.env.AUTH_GOOGLE_METHOD),
|
|
||||||
// isManual: true,
|
|
||||||
credentials: {
|
|
||||||
clientId: process.env.AUTH_GOOGLE_ID || "",
|
|
||||||
clientSecret: process.env.AUTH_GOOGLE_SECRET || "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "github",
|
|
||||||
icon: "iconoir:github",
|
|
||||||
isActive: Boolean(process.env.AUTH_GITHUB_METHOD),
|
|
||||||
credentials: {
|
|
||||||
clientId: process.env.AUTH_GITHUB_ID || "",
|
|
||||||
clientSecret: process.env.AUTH_GITHUB_SECRET || "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "credential",
|
|
||||||
isActive: true,
|
|
||||||
icon: "proicons:key",
|
|
||||||
isManual: true,
|
|
||||||
credentials: {
|
|
||||||
clientId: "",
|
|
||||||
clientSecret: "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|||||||
@@ -1,75 +1,81 @@
|
|||||||
import {NextRequest, NextResponse} from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import {loggingMiddleware} from "@/middleware/loggingMiddleware";
|
import { loggingMiddleware } from "@/middleware/loggingMiddleware";
|
||||||
import {errorHandler} from "@/middleware/errorHandler";
|
import { errorHandler } from "@/middleware/errorHandler";
|
||||||
import {auth} from "@/lib/auth/auth";
|
import { auth } from "@/lib/auth/auth";
|
||||||
import {headers} from "next/headers";
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
export async function proxy(request: NextRequest) {
|
export async function proxy(request: NextRequest) {
|
||||||
const url = request.nextUrl.clone();
|
const url = request.nextUrl.clone();
|
||||||
const redirectUrl = encodeURIComponent(request.nextUrl.pathname)
|
const redirectUrl = encodeURIComponent(request.nextUrl.pathname);
|
||||||
|
|
||||||
if (url.pathname.startsWith("/dashboard")) {
|
if (url.pathname.startsWith("/dashboard")) {
|
||||||
const session = await auth.api.getSession({
|
const session = await auth.api.getSession({
|
||||||
headers: await headers(),
|
headers: await headers(),
|
||||||
});
|
});
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return NextResponse.redirect(new URL(`/login?redirect=${redirectUrl}`, request.url));
|
return NextResponse.redirect(
|
||||||
}
|
new URL(`/login?redirect=${redirectUrl}`, request.url),
|
||||||
if (session.user.banned) {
|
);
|
||||||
await auth.api.signOut({headers: await headers()});
|
|
||||||
return NextResponse.redirect(new URL("/login?error=banned", request.url));
|
|
||||||
}
|
|
||||||
if (session.user.role === "pending") {
|
|
||||||
await auth.api.signOut({headers: await headers()});
|
|
||||||
return NextResponse.redirect(new URL(`/login?error=pending?redirect=${redirectUrl}`, request.url));
|
|
||||||
}
|
|
||||||
if (url.pathname === "/dashboard") {
|
|
||||||
return NextResponse.redirect(new URL(`/dashboard/home`, request.url));
|
|
||||||
}
|
|
||||||
return NextResponse.next();
|
|
||||||
}
|
}
|
||||||
|
if (session.user.banned) {
|
||||||
|
await auth.api.signOut({ headers: await headers() });
|
||||||
|
return NextResponse.redirect(new URL("/login?error=banned", request.url));
|
||||||
|
}
|
||||||
|
if (session.user.role === "pending") {
|
||||||
|
await auth.api.signOut({ headers: await headers() });
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL(`/login?error=pending?redirect=${redirectUrl}`, request.url),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (url.pathname === "/dashboard") {
|
||||||
|
return NextResponse.redirect(new URL(`/dashboard/home`, request.url));
|
||||||
|
}
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
if (url.pathname.startsWith("/api/auth")) {
|
if (url.pathname.startsWith("/api/auth")) {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname.startsWith("/api")) {
|
if (url.pathname.startsWith("/api")) {
|
||||||
const routeExists = checkRouteExists(url.pathname);
|
const routeExists = checkRouteExists(url.pathname);
|
||||||
if (!routeExists) {
|
if (!routeExists) {
|
||||||
return new NextResponse(JSON.stringify({message: "This API route does not exist.", status: 404}), {
|
return new NextResponse(
|
||||||
status: 404,
|
JSON.stringify({
|
||||||
headers: {"Content-Type": "application/json"},
|
message: "This API route does not exist.",
|
||||||
});
|
status: 404,
|
||||||
}
|
}),
|
||||||
}
|
{
|
||||||
try {
|
status: 404,
|
||||||
loggingMiddleware(request);
|
headers: { "Content-Type": "application/json" },
|
||||||
} catch (err) {
|
},
|
||||||
errorHandler(err);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
loggingMiddleware(request);
|
||||||
|
} catch (err) {
|
||||||
|
errorHandler(err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkRouteExists(pathname: string) {
|
function checkRouteExists(pathname: string) {
|
||||||
const routePatterns = [
|
const routePatterns = [
|
||||||
/^\/api\/agent\/[^/]+\/status\/?$/,
|
/^\/api\/agent\/[^/]+\/status\/?$/,
|
||||||
/^\/api\/agent\/[^/]+\/backup\/?$/,
|
/^\/api\/agent\/[^/]+\/backup\/?$/,
|
||||||
/^\/api\/agent\/[^/]+\/backup\/upload\/init\/?$/,
|
/^\/api\/agent\/[^/]+\/backup\/upload\/init\/?$/,
|
||||||
/^\/api\/agent\/[^/]+\/backup\/upload\/status\/?$/,
|
/^\/api\/agent\/[^/]+\/backup\/upload\/status\/?$/,
|
||||||
/^\/api\/agent\/[^/]+\/restore\/?$/,
|
/^\/api\/agent\/[^/]+\/restore\/?$/,
|
||||||
/^\/api\/files\/images\/[^/]+\/?$/,
|
/^\/api\/files\/images\/[^/]+\/?$/,
|
||||||
/^\/api\/files\/backups\/?$/,
|
/^\/api\/files\/backups\/?$/,
|
||||||
/^\/api\/tus\/hooks\/?$/,
|
/^\/api\/tus\/hooks\/?$/,
|
||||||
/^\/api\/events\/?$/,
|
/^\/api\/events\/?$/,
|
||||||
/^\/api\/config\/?$/,
|
/^\/api\/config\/?$/,
|
||||||
/^\/api\/google\/drive\/callback\/?$/,
|
/^\/api\/google\/drive\/callback\/?$/,
|
||||||
];
|
];
|
||||||
return routePatterns.some((pattern) => pattern.test(pathname));
|
return routePatterns.some((pattern) => pattern.test(pathname));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: ["/api/:path*", "/dashboard/:path*", "/dashboard"],
|
||||||
"/api/:path*",
|
|
||||||
"/dashboard/:path*",
|
|
||||||
"/dashboard",
|
|
||||||
]
|
|
||||||
};
|
};
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
@@ -1,95 +0,0 @@
|
|||||||
#!/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 @@
|
|||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
Binary file not shown.
@@ -1,6 +0,0 @@
|
|||||||
import express from "express";
|
|
||||||
import router from "../src/features/api/router";
|
|
||||||
|
|
||||||
export function mountApi(app: express.Express) {
|
|
||||||
app.use("/services/v1", router);
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
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();
|
|
||||||
@@ -6,7 +6,7 @@ import {env} from "@/env.mjs";
|
|||||||
|
|
||||||
interface EmailCreateUserProps {
|
interface EmailCreateUserProps {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EmailCreateUser = ({email, password}: EmailCreateUserProps) => {
|
export const EmailCreateUser = ({email, password}: EmailCreateUserProps) => {
|
||||||
@@ -23,9 +23,15 @@ export const EmailCreateUser = ({email, password}: EmailCreateUserProps) => {
|
|||||||
<strong>Email: </strong>{email}
|
<strong>Email: </strong>{email}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text className="text-[14px] text-black leading-[24px]">
|
{password ? (
|
||||||
<strong>Default password: </strong>{password}
|
<Text className="text-[14px] text-black leading-[24px]">
|
||||||
</Text>
|
<strong>Default password: </strong>{password}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text className="text-[14px] text-black leading-[24px]">
|
||||||
|
You can log in using one of the single sign-on (SSO) providers configured by your administrator.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
<Section className="mt-[32px] mb-[32px] text-center">
|
<Section className="mt-[32px] mb-[32px] text-center">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -208,12 +208,14 @@ type UseZodFormProps<Z extends ZodSchema> = Exclude<
|
|||||||
schema: Z;
|
schema: Z;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const useZodForm = <Z extends ZodSchema>({
|
const useZodForm = <Z extends ZodSchema>({
|
||||||
schema,
|
schema,
|
||||||
...formProps
|
...formProps
|
||||||
}: UseZodFormProps<Z>) =>
|
}: UseZodFormProps<Z>) =>
|
||||||
useForm({
|
useForm({
|
||||||
...formProps,
|
...formProps,
|
||||||
|
// @ts-ignore
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,121 +1,133 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {useEffect, useState} from "react";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
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";
|
|
||||||
import {LoginSchema, LoginType} from "@/components/wrappers/auth/login/login-form/login-form.schema";
|
|
||||||
import {signIn} from "@/lib/auth/auth-client";
|
|
||||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {useRouter} from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import {PasswordInput} from "@/components/ui/password-input";
|
import { useEffect } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
useZodForm,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { PasswordInput } from "@/components/ui/password-input";
|
||||||
|
import {
|
||||||
|
LoginSchema,
|
||||||
|
type LoginType,
|
||||||
|
} from "@/components/wrappers/auth/login/login-form/login-form.schema";
|
||||||
|
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
|
||||||
|
import { signIn } from "@/lib/auth/auth-client";
|
||||||
|
|
||||||
export type loginFormProps = {
|
export type loginFormProps = {
|
||||||
defaultValues?: LoginType;
|
defaultValues?: LoginType;
|
||||||
|
isPasskeyEnabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LoginForm = (props: loginFormProps) => {
|
export const LoginForm = (props: loginFormProps) => {
|
||||||
|
const { isPasskeyEnabled = false } = props;
|
||||||
|
|
||||||
|
const url = useSearchParams();
|
||||||
|
|
||||||
const [urlParams, setUrlParams] = useState<URLSearchParams>();
|
const form = useZodForm({
|
||||||
|
schema: LoginSchema,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const router = useRouter();
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
setUrlParams(urlParams);
|
|
||||||
const error = urlParams.get("error");
|
|
||||||
if (error?.includes("pending")) {
|
|
||||||
toast.error("Your account is not active.");
|
|
||||||
urlParams.delete("error");
|
|
||||||
window.history.replaceState({}, document.title, window.location.pathname + "?" + urlParams.toString());
|
|
||||||
}
|
|
||||||
if (error?.includes("invalid_or_expired_token")) {
|
|
||||||
toast.error("Password reset invalid token.");
|
|
||||||
urlParams.delete("error");
|
|
||||||
window.history.replaceState({}, document.title, window.location.pathname + "?" + urlParams.toString());
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const form = useZodForm({
|
const mutation = useMutation({
|
||||||
schema: LoginSchema,
|
mutationFn: async (values: LoginType) => {
|
||||||
});
|
await signIn.email(
|
||||||
|
{
|
||||||
const router = useRouter();
|
password: values.password,
|
||||||
|
email: values.email,
|
||||||
const mutation = useMutation({
|
callbackURL: url?.get("redirect") ?? "/dashboard",
|
||||||
mutationFn: async (values: LoginType) => {
|
|
||||||
await signIn.email(
|
|
||||||
{
|
|
||||||
password: values.password,
|
|
||||||
email: values.email,
|
|
||||||
callbackURL: urlParams?.get("redirect") ?? "/dashboard",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess: (context) => {
|
|
||||||
if (context.data.twoFactorRedirect) {
|
|
||||||
//@ts-ignore
|
|
||||||
router.push("/guard?redirect=" + encodeURIComponent(context.data.callbackURL || "/dashboard"));
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success("Login success");
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error(error.error.message);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
{
|
||||||
|
onSuccess: (context) => {
|
||||||
|
if (context.data.twoFactorRedirect) {
|
||||||
|
router.push(
|
||||||
|
"/guard?redirect=" +
|
||||||
|
encodeURIComponent(context.data.callbackURL || "/dashboard"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
toast.success("Login success");
|
||||||
<Form
|
},
|
||||||
form={form}
|
onError: (error) => {
|
||||||
className="flex flex-col gap-4 mb-1"
|
toast.error(error.error.message);
|
||||||
onSubmit={async (values) => {
|
},
|
||||||
await mutation.mutateAsync(values);
|
},
|
||||||
}}
|
);
|
||||||
>
|
},
|
||||||
<FormField
|
});
|
||||||
control={form.control}
|
|
||||||
name="email"
|
return (
|
||||||
defaultValue=""
|
<Form
|
||||||
render={({field}) => (
|
form={form}
|
||||||
<FormItem>
|
className="flex flex-col gap-4 mb-1"
|
||||||
<FormLabel>Email Address</FormLabel>
|
onSubmit={async (values) => {
|
||||||
<FormControl>
|
await mutation.mutateAsync(values);
|
||||||
<Input autoComplete="email" autoFocus placeholder="example@portabase.io" {...field} />
|
}}
|
||||||
</FormControl>
|
>
|
||||||
<FormMessage/>
|
<FormField
|
||||||
</FormItem>
|
control={form.control}
|
||||||
)}
|
name="email"
|
||||||
/>
|
defaultValue=""
|
||||||
<FormField
|
render={({ field }) => (
|
||||||
control={form.control}
|
<FormItem>
|
||||||
name="password"
|
<FormLabel>Email Address</FormLabel>
|
||||||
defaultValue=""
|
<FormControl>
|
||||||
render={({field}) => (
|
<Input
|
||||||
<FormItem>
|
autoComplete="email"
|
||||||
<div className="flex items-center justify-between">
|
autoFocus
|
||||||
<FormLabel>Password</FormLabel>
|
placeholder="example@portabase.io"
|
||||||
<div className="text-center text-sm">
|
{...field}
|
||||||
<Link href={"/forgot-password"} className="hover:underline ml-1">
|
/>
|
||||||
Forgot your password ?
|
</FormControl>
|
||||||
</Link>
|
<FormMessage />
|
||||||
</div>
|
</FormItem>
|
||||||
</div>
|
)}
|
||||||
<FormControl>
|
/>
|
||||||
<PasswordInput autoComplete="current-password webauthn"
|
<FormField
|
||||||
placeholder={"Enter your password"} {...field} />
|
control={form.control}
|
||||||
</FormControl>
|
name="password"
|
||||||
<FormMessage/>
|
defaultValue=""
|
||||||
</FormItem>
|
render={({ field }) => (
|
||||||
)}
|
<FormItem>
|
||||||
/>
|
<div className="flex items-center justify-between">
|
||||||
<ButtonWithLoading className="mt-2 h-11" isPending={mutation.isPending}>
|
<FormLabel>Password</FormLabel>
|
||||||
Login
|
<div className="text-center text-sm">
|
||||||
</ButtonWithLoading>
|
<Link
|
||||||
</Form>
|
href={"/forgot-password"}
|
||||||
);
|
className="hover:underline ml-1"
|
||||||
|
>
|
||||||
|
Forgot your password ?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<PasswordInput
|
||||||
|
autoComplete={
|
||||||
|
isPasskeyEnabled
|
||||||
|
? "current-password webauthn"
|
||||||
|
: "current-password"
|
||||||
|
}
|
||||||
|
placeholder={"Enter your password"}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<ButtonWithLoading className="mt-2 h-11" isPending={mutation.isPending}>
|
||||||
|
Login
|
||||||
|
</ButtonWithLoading>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,156 +1,169 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {useRouter} from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {useMutation} from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import {toast} from "sonner";
|
import { toast } from "sonner";
|
||||||
import {CardContent, CardHeader} from "@/components/ui/card";
|
import { CardContent, CardHeader } from "@/components/ui/card";
|
||||||
import {Form} from "@/components/ui/form";
|
import { Form } from "@/components/ui/form";
|
||||||
import {TooltipProvider, TooltipTrigger, Tooltip, TooltipContent} from "@/components/ui/tooltip";
|
import {
|
||||||
import {RegisterSchema, RegisterType} from "@/components/wrappers/auth/register/register-form/register-form.schema";
|
TooltipProvider,
|
||||||
import {signUp} from "@/lib/auth/auth-client";
|
TooltipTrigger,
|
||||||
import {useZodForm} from "@/components/ui/form";
|
Tooltip,
|
||||||
import {CardAuth} from "@/features/layout/card-auth";
|
TooltipContent,
|
||||||
import {FormField} from "@/components/ui/form";
|
} from "@/components/ui/tooltip";
|
||||||
import {FormItem} from "@/components/ui/form";
|
import {
|
||||||
import {FormLabel} from "@/components/ui/form";
|
RegisterSchema,
|
||||||
import {FormControl} from "@/components/ui/form";
|
RegisterType,
|
||||||
import {FormMessage} from "@/components/ui/form";
|
} from "@/components/wrappers/auth/register/register-form/register-form.schema";
|
||||||
import {Input} from "@/components/ui/input";
|
import { signUp } from "@/lib/auth/auth-client";
|
||||||
import {PasswordInput} from "@/components/ui/password-input";
|
import { useZodForm } from "@/components/ui/form";
|
||||||
import {Button} from "@/components/ui/button";
|
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 Link from "next/link";
|
||||||
import {Info} from "lucide-react";
|
import { Info } from "lucide-react";
|
||||||
|
|
||||||
export type registerFormProps = {
|
export type registerFormProps = {
|
||||||
defaultValues?: RegisterType;
|
defaultValues?: RegisterType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RegisterForm = (props: registerFormProps) => {
|
export const RegisterForm = (props: registerFormProps) => {
|
||||||
|
const form = useZodForm({
|
||||||
|
schema: RegisterSchema,
|
||||||
|
});
|
||||||
|
|
||||||
const form = useZodForm({
|
const router = useRouter();
|
||||||
schema: RegisterSchema,
|
const mutation = useMutation({
|
||||||
});
|
mutationFn: async (values: RegisterType) => {
|
||||||
|
await signUp.email(values, {
|
||||||
const router = useRouter();
|
onSuccess: () => {
|
||||||
const mutation = useMutation({
|
toast.success(`Account successfully created`);
|
||||||
mutationFn: async (values: RegisterType) => {
|
router.refresh();
|
||||||
// @ts-ignore
|
router.push(`/login`);
|
||||||
await signUp.email(values, {
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success(`Account successfully created`);
|
|
||||||
router.refresh();
|
|
||||||
router.push(`/login`);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error(error.error.message);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
onError: (error) => {
|
||||||
|
toast.error(error.error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
return (
|
<TooltipProvider>
|
||||||
<TooltipProvider>
|
<CardAuth>
|
||||||
<CardAuth>
|
<CardHeader>
|
||||||
<CardHeader>
|
<div className="grid gap-2 text-center mb-2">
|
||||||
<div className="grid gap-2 text-center mb-2">
|
<h1 className="text-3xl font-bold">Create an account</h1>
|
||||||
<h1 className="text-3xl font-bold">Create an account</h1>
|
<p className="text-balance text-muted-foreground">
|
||||||
<p className="text-balance text-muted-foreground">Enter your informations below to register</p>
|
Enter your informations below to register
|
||||||
</div>
|
</p>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
</CardHeader>
|
||||||
<Form
|
<CardContent>
|
||||||
form={form}
|
<Form
|
||||||
className="flex flex-col gap-4"
|
form={form}
|
||||||
onSubmit={async (values) => {
|
className="flex flex-col gap-4"
|
||||||
await mutation.mutateAsync(values);
|
onSubmit={async (values) => {
|
||||||
}}
|
await mutation.mutateAsync(values);
|
||||||
>
|
}}
|
||||||
<FormField
|
>
|
||||||
control={form.control}
|
<FormField
|
||||||
name="name"
|
control={form.control}
|
||||||
defaultValue=""
|
name="name"
|
||||||
render={({field}) => (
|
defaultValue=""
|
||||||
<FormItem>
|
render={({ field }) => (
|
||||||
<FormLabel>Name</FormLabel>
|
<FormItem>
|
||||||
<FormControl>
|
<FormLabel>Name</FormLabel>
|
||||||
<Input placeholder="Your name" {...field} />
|
<FormControl>
|
||||||
</FormControl>
|
<Input placeholder="Your name" {...field} />
|
||||||
<FormMessage/>
|
</FormControl>
|
||||||
</FormItem>
|
<FormMessage />
|
||||||
)}
|
</FormItem>
|
||||||
/>
|
)}
|
||||||
<FormField
|
/>
|
||||||
control={form.control}
|
<FormField
|
||||||
name="email"
|
control={form.control}
|
||||||
defaultValue=""
|
name="email"
|
||||||
render={({field}) => (
|
defaultValue=""
|
||||||
<FormItem>
|
render={({ field }) => (
|
||||||
<FormLabel>Email</FormLabel>
|
<FormItem>
|
||||||
<FormControl>
|
<FormLabel>Email</FormLabel>
|
||||||
<Input placeholder="example@portabase.io" {...field} />
|
<FormControl>
|
||||||
</FormControl>
|
<Input placeholder="example@portabase.io" {...field} />
|
||||||
<FormMessage/>
|
</FormControl>
|
||||||
</FormItem>
|
<FormMessage />
|
||||||
)}
|
</FormItem>
|
||||||
/>
|
)}
|
||||||
<FormField
|
/>
|
||||||
control={form.control}
|
<FormField
|
||||||
name="password"
|
control={form.control}
|
||||||
defaultValue=""
|
name="password"
|
||||||
render={({field}) => (
|
defaultValue=""
|
||||||
<FormItem>
|
render={({ field }) => (
|
||||||
<FormLabel className="flex">
|
<FormItem>
|
||||||
Password
|
<FormLabel className="flex">
|
||||||
<TooltipProvider>
|
Password
|
||||||
<Tooltip>
|
<TooltipProvider>
|
||||||
<TooltipTrigger asChild>
|
<Tooltip>
|
||||||
<Info className="ml-3" size="15"/>
|
<TooltipTrigger asChild>
|
||||||
</TooltipTrigger>
|
<Info size="15" />
|
||||||
<TooltipContent>
|
</TooltipTrigger>
|
||||||
<p>
|
<TooltipContent>
|
||||||
Min. 8 characters, 1 uppercase (A-Z), 1 lowercase (a-z), 1
|
<p>
|
||||||
number (0-9), 1 special character (!, @,
|
Min. 8 characters, 1 uppercase (A-Z), 1 lowercase
|
||||||
etc.)
|
(a-z), 1 number (0-9), 1 special character (!, @,
|
||||||
</p>
|
etc.)
|
||||||
</TooltipContent>
|
</p>
|
||||||
</Tooltip>
|
</TooltipContent>
|
||||||
</TooltipProvider>
|
</Tooltip>
|
||||||
</FormLabel>
|
</TooltipProvider>
|
||||||
<FormControl>
|
</FormLabel>
|
||||||
<PasswordInput placeholder="Your password" {...field} />
|
<FormControl>
|
||||||
</FormControl>
|
<PasswordInput placeholder="Your password" {...field} />
|
||||||
<FormMessage/>
|
</FormControl>
|
||||||
</FormItem>
|
<FormMessage />
|
||||||
)}
|
</FormItem>
|
||||||
/>
|
)}
|
||||||
<FormField
|
/>
|
||||||
control={form.control}
|
<FormField
|
||||||
name="confirmPassword"
|
control={form.control}
|
||||||
defaultValue=""
|
name="confirmPassword"
|
||||||
render={({field}) => (
|
defaultValue=""
|
||||||
<FormItem>
|
render={({ field }) => (
|
||||||
<FormLabel>Password Confirmation</FormLabel>
|
<FormItem>
|
||||||
<FormControl>
|
<FormLabel>Password Confirmation</FormLabel>
|
||||||
<PasswordInput placeholder="Conform your password" {...field} />
|
<FormControl>
|
||||||
</FormControl>
|
<PasswordInput
|
||||||
<FormMessage/>
|
placeholder="Conform your password"
|
||||||
</FormItem>
|
{...field}
|
||||||
)}
|
/>
|
||||||
/>
|
</FormControl>
|
||||||
<Button type="submit" className="h-11" disabled={mutation.isPending}>
|
<FormMessage />
|
||||||
Sign up
|
</FormItem>
|
||||||
</Button>
|
)}
|
||||||
<div className="mt-4 text-center text-sm">
|
/>
|
||||||
Already have an account ?{" "}
|
<Button
|
||||||
<Link href="/login" className="underline">
|
type="submit"
|
||||||
Sign in
|
className="h-11"
|
||||||
</Link>
|
disabled={mutation.isPending}
|
||||||
</div>
|
>
|
||||||
|
Sign up
|
||||||
</Form>
|
</Button>
|
||||||
</CardContent>
|
<div className="mt-4 text-center text-sm">
|
||||||
</CardAuth>
|
Already have an account ?{" "}
|
||||||
</TooltipProvider>
|
<Link href="/login" className="underline">
|
||||||
);
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</CardAuth>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,29 +1,53 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { authClient } from "@/lib/auth/auth-client";
|
import { authClient, passkey } from "@/lib/auth/auth-client";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {AuthProviderConfig} from "../../../../portabase.config";
|
import { Icon } from "@iconify/react";
|
||||||
import {Icon} from "@iconify/react";
|
import { AuthProviderConfig } from "@/lib/auth/config";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
export function SocialAuthButtons({ providers }: { providers: AuthProviderConfig[] }) {
|
export function SocialAuthButtons({ providers }: { providers: AuthProviderConfig[] }) {
|
||||||
const socialProviders = providers.filter(p => p.isActive && !p.isManual);
|
const socialProviders = providers.filter((p) => p.isActive && p.type !== "credential");
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState<string | null>(null);
|
const [isLoading, setIsLoading] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleSocialSignIn = async (providerId: string) => {
|
const handleSocialSignIn = async (provider: AuthProviderConfig) => {
|
||||||
setIsLoading(providerId);
|
setIsLoading(provider.id);
|
||||||
try {
|
try {
|
||||||
const { error } = await authClient.signIn.social({
|
let result;
|
||||||
provider: providerId as "google" | "github",
|
if (provider.id === "passkey") {
|
||||||
callbackURL: "/dashboard",
|
result = await authClient.signIn.passkey({
|
||||||
});
|
fetchOptions: {
|
||||||
|
onSuccess() {
|
||||||
if (error) {
|
router.push("/dashboard");
|
||||||
toast.error("An error occurred while signing in with the provider. Please try again.");
|
},
|
||||||
|
onError() {
|
||||||
|
toast.error("An error occurred during passkey authentication. Please try again.");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (provider.type === "sso") {
|
||||||
|
result = await authClient.signIn.sso({
|
||||||
|
providerId: provider.id,
|
||||||
|
providerType: "oidc",
|
||||||
|
callbackURL: "/dashboard",
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
|
result = await authClient.signIn.social({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
provider: provider.id as any,
|
||||||
|
callbackURL: "/dashboard",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
toast.error("An error occurred while signing in with the provider. Please try again.");
|
||||||
|
} else if (provider.id !== "passkey") {
|
||||||
toast.success("Redirecting to provider...");
|
toast.success("Redirecting to provider...");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -38,29 +62,24 @@ export function SocialAuthButtons({ providers }: { providers: AuthProviderConfig
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 w-full">
|
<div className="flex flex-col gap-2 w-full">
|
||||||
{socialProviders.map((provider) => (
|
{socialProviders.map((provider) => (
|
||||||
<Button key={provider.id} variant="outline" className="w-full gap-2" onClick={() => handleSocialSignIn(provider.id)} disabled={!!isLoading}>
|
<Button key={provider.id} variant="outline" className="w-full gap-2" onClick={() => handleSocialSignIn(provider)} disabled={!!isLoading}>
|
||||||
{isLoading === provider.id ? <Loader2 className="h-4 w-4 animate-spin" /> :
|
{isLoading === provider.id ? (
|
||||||
<Icon icon={provider.icon} className="h-4 w-4"/>
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
}
|
) : provider.icon.startsWith("/") || provider.icon.startsWith("http") ? (
|
||||||
<span>{PROVIDERS_TEXT[provider.id].title}</span>
|
<Image
|
||||||
|
src={provider.icon}
|
||||||
|
alt={provider.id || "icon"}
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
className="h-4 w-4"
|
||||||
|
unoptimized={provider.icon.startsWith("http")}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Icon icon={provider.icon} className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<span>{provider.title || provider.name}</span>
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const PROVIDERS_TEXT = {
|
|
||||||
credential: {
|
|
||||||
title: "Password",
|
|
||||||
description: "Use your email address and password to sign in."
|
|
||||||
},
|
|
||||||
google: {
|
|
||||||
title: "Google",
|
|
||||||
description: "Sign in with your Google account."
|
|
||||||
},
|
|
||||||
github: {
|
|
||||||
title: "GitHub",
|
|
||||||
description: "Sign in with your GitHub account."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,161 +1,198 @@
|
|||||||
"use client"
|
"use client";
|
||||||
import {Button, ButtonVariantsProps} from "@/components/ui/button";
|
import { Button, ButtonVariantsProps } from "@/components/ui/button";
|
||||||
import {ReactNode, useState} from "react";
|
import { ReactNode, useState } from "react";
|
||||||
import {Loader2} from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover";
|
import {
|
||||||
import {cn} from "@/lib/utils";
|
Popover,
|
||||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
|
||||||
export type ButtonWithConfirmProps = {
|
export type ButtonWithConfirmProps = {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
button?: {
|
button?: {
|
||||||
main: {
|
main: {
|
||||||
className?: string;
|
className?: string;
|
||||||
type?: "button" | "submit" | "reset" | undefined;
|
type?: "button" | "submit" | "reset" | undefined;
|
||||||
text?: string;
|
text?: string;
|
||||||
icon?: any;
|
icon?: any;
|
||||||
variant?: ButtonVariantsProps["variant"];
|
variant?: ButtonVariantsProps["variant"];
|
||||||
size?: ButtonVariantsProps["size"];
|
size?: ButtonVariantsProps["size"];
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
tooltipText?: string;
|
tooltipText?: string;
|
||||||
};
|
|
||||||
confirm: {
|
|
||||||
className?: string;
|
|
||||||
text: string;
|
|
||||||
icon?: any;
|
|
||||||
variant?: ButtonVariantsProps["variant"];
|
|
||||||
size?: ButtonVariantsProps["size"];
|
|
||||||
onClick?: () => void;
|
|
||||||
};
|
|
||||||
cancel: {
|
|
||||||
className?: string;
|
|
||||||
text: string;
|
|
||||||
icon?: any;
|
|
||||||
variant?: ButtonVariantsProps["variant"];
|
|
||||||
size?: ButtonVariantsProps["size"];
|
|
||||||
onClick?: () => void;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
children?: ReactNode;
|
confirm: {
|
||||||
onConfirm?: (e: React.MouseEvent) => void;
|
className?: string;
|
||||||
onCancel?: (e: React.MouseEvent) => void;
|
text: string;
|
||||||
confirmButtonText?: string;
|
icon?: any;
|
||||||
cancelButtonText?: string;
|
variant?: ButtonVariantsProps["variant"];
|
||||||
isPending?: boolean;
|
size?: ButtonVariantsProps["size"];
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
cancel: {
|
||||||
|
className?: string;
|
||||||
|
text: string;
|
||||||
|
icon?: any;
|
||||||
|
variant?: ButtonVariantsProps["variant"];
|
||||||
|
size?: ButtonVariantsProps["size"];
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
children?: ReactNode;
|
||||||
|
onConfirm?: (e: React.MouseEvent) => void;
|
||||||
|
onCancel?: (e: React.MouseEvent) => void;
|
||||||
|
confirmButtonText?: string;
|
||||||
|
cancelButtonText?: string;
|
||||||
|
isPending?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
|
export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
|
||||||
const [isConfirming, setIsConfirming] = useState(false);
|
const [isConfirming, setIsConfirming] = useState(false);
|
||||||
|
|
||||||
const isLegacy = !!props.button;
|
const isLegacy = !!props.button;
|
||||||
const isDisabled = isLegacy ? !!props.button?.main.disabled : false;
|
const isDisabled = isLegacy ? !!props.button?.main.disabled : false;
|
||||||
|
|
||||||
const handleConfirm = (e: React.MouseEvent) => {
|
const handleConfirm = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (isLegacy) {
|
||||||
|
props.button?.confirm.onClick?.();
|
||||||
|
} else {
|
||||||
|
props.onConfirm?.(e);
|
||||||
|
}
|
||||||
|
setIsConfirming(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (isLegacy) {
|
||||||
|
props.button?.cancel.onClick?.();
|
||||||
|
} else {
|
||||||
|
props.onCancel?.(e);
|
||||||
|
}
|
||||||
|
setIsConfirming(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerContent = isLegacy ? (
|
||||||
|
<Button
|
||||||
|
type={props.button?.main.type}
|
||||||
|
disabled={isDisabled}
|
||||||
|
variant={props.button?.main.variant ?? "default"}
|
||||||
|
size={props.button?.main.size ?? "default"}
|
||||||
|
className={props.button?.main.className}
|
||||||
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (isLegacy) {
|
if (!isDisabled) setIsConfirming(true);
|
||||||
props.button?.confirm.onClick?.();
|
}}
|
||||||
} else {
|
>
|
||||||
props.onConfirm?.(e);
|
{props.isPending && <Loader2 className="animate-spin mr-4" size={16} />}
|
||||||
}
|
{props.button?.main.icon}
|
||||||
setIsConfirming(false);
|
{props.button?.main.text && <span>{props.button.main.text}</span>}
|
||||||
};
|
</Button>
|
||||||
|
) : (
|
||||||
const handleCancel = (e: React.MouseEvent) => {
|
<div
|
||||||
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (isLegacy) {
|
setIsConfirming(true);
|
||||||
props.button?.cancel.onClick?.();
|
}}
|
||||||
} else {
|
>
|
||||||
props.onCancel?.(e);
|
{props.children}
|
||||||
}
|
</div>
|
||||||
setIsConfirming(false);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
const triggerContent = isLegacy ? (
|
const withTooltip =
|
||||||
<Button
|
isLegacy && props.button?.main.tooltipText && isDisabled ? (
|
||||||
type={props.button?.main.type}
|
<TooltipProvider>
|
||||||
disabled={isDisabled}
|
<Tooltip>
|
||||||
variant={props.button?.main.variant ?? "default"}
|
<TooltipTrigger asChild>
|
||||||
size={props.button?.main.size ?? "default"}
|
<div>{triggerContent}</div>
|
||||||
className={props.button?.main.className}
|
</TooltipTrigger>
|
||||||
onClick={(e) => {
|
<TooltipContent>
|
||||||
e.preventDefault();
|
<p>{props.button?.main.tooltipText}</p>
|
||||||
e.stopPropagation();
|
</TooltipContent>
|
||||||
if (!isDisabled) setIsConfirming(true);
|
</Tooltip>
|
||||||
}}
|
</TooltipProvider>
|
||||||
>
|
|
||||||
{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>
|
|
||||||
) : (
|
) : (
|
||||||
<div onClick={(e) => {
|
triggerContent
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsConfirming(true);
|
|
||||||
}}>
|
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const withTooltip = isLegacy && props.button?.main.tooltipText && isDisabled ? (
|
return (
|
||||||
<TooltipProvider>
|
<Popover open={isConfirming} onOpenChange={setIsConfirming}>
|
||||||
<Tooltip>
|
<PopoverTrigger asChild>{withTooltip}</PopoverTrigger>
|
||||||
<TooltipTrigger asChild>
|
<PopoverContent
|
||||||
<div>{triggerContent}</div>
|
className="w-80"
|
||||||
</TooltipTrigger>
|
onPointerDownOutside={(e) => e.preventDefault()}
|
||||||
<TooltipContent>
|
onInteractOutside={(e) => e.preventDefault()}
|
||||||
<p>{props.button?.main.tooltipText}</p>
|
onClick={(e) => e.stopPropagation()}
|
||||||
</TooltipContent>
|
onMouseMove={(e) => e.stopPropagation()}
|
||||||
</Tooltip>
|
onMouseEnter={(e) => e.stopPropagation()}
|
||||||
</TooltipProvider>
|
onMouseLeave={(e) => e.stopPropagation()}
|
||||||
) : triggerContent;
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
return (
|
<div className="grid gap-4">
|
||||||
<Popover open={isConfirming} onOpenChange={setIsConfirming}>
|
<div className="space-y-2">
|
||||||
<PopoverTrigger asChild>
|
<h4 className="font-medium leading-none">{props.title}</h4>
|
||||||
{withTooltip}
|
<p className="text-sm text-muted-foreground">{props.description}</p>
|
||||||
</PopoverTrigger>
|
</div>
|
||||||
<PopoverContent
|
<div className="grid gap-2">
|
||||||
className="w-80"
|
<Button
|
||||||
onPointerDownOutside={(e) => e.preventDefault()}
|
onClick={handleConfirm}
|
||||||
onInteractOutside={(e) => e.preventDefault()}
|
variant={
|
||||||
onClick={(e) => e.stopPropagation()}
|
isLegacy
|
||||||
onMouseMove={(e) => e.stopPropagation()}
|
? (props.button?.confirm.variant ?? "default")
|
||||||
onMouseEnter={(e) => e.stopPropagation()}
|
: "default"
|
||||||
onMouseLeave={(e) => e.stopPropagation()}
|
}
|
||||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
size={
|
||||||
|
isLegacy ? (props.button?.confirm.size ?? "default") : "default"
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
isLegacy ? props.button?.confirm.className : "",
|
||||||
|
"w-full",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="grid gap-4">
|
{props.isPending && (
|
||||||
<div className="space-y-2">
|
<Loader2 className="animate-spin mr-4" size={16} />
|
||||||
<h4 className="font-medium leading-none">{props.title}</h4>
|
)}
|
||||||
<p className="text-sm text-muted-foreground">{props.description}</p>
|
{isLegacy && props.button?.confirm.icon}
|
||||||
</div>
|
<span>
|
||||||
<div className="grid gap-2">
|
{isLegacy
|
||||||
<Button
|
? props.button?.confirm.text
|
||||||
onClick={handleConfirm}
|
: (props.confirmButtonText ?? "Confirm")}
|
||||||
variant={isLegacy ? (props.button?.confirm.variant ?? "default") : "default"}
|
</span>
|
||||||
size={isLegacy ? (props.button?.main.size ?? "default") : "default"}
|
</Button>
|
||||||
className={cn(isLegacy ? props.button?.main.className : "", "w-full")}
|
<Button
|
||||||
>
|
variant={
|
||||||
{props.isPending && <Loader2 className="animate-spin mr-4" size={16}/>}
|
isLegacy
|
||||||
{isLegacy && props.button?.confirm.icon}
|
? (props.button?.cancel.variant ?? "outline")
|
||||||
<span>{isLegacy ? props.button?.confirm.text : (props.confirmButtonText ?? "Confirm")}</span>
|
: "outline"
|
||||||
</Button>
|
}
|
||||||
<Button
|
onClick={handleCancel}
|
||||||
variant={isLegacy ? (props.button?.cancel.variant ?? "outline") : "outline"}
|
className={cn(
|
||||||
onClick={handleCancel}
|
isLegacy ? props.button?.cancel.className : "",
|
||||||
className={cn(isLegacy ? props.button?.main.className : "", "w-full")}
|
"w-full",
|
||||||
size={isLegacy ? (props.button?.main.size ?? "default") : "default"}
|
)}
|
||||||
>
|
size={
|
||||||
{isLegacy ? (props.button?.cancel.text ?? "Cancel") : (props.cancelButtonText ?? "Cancel")}
|
isLegacy ? (props.button?.cancel.size ?? "default") : "default"
|
||||||
</Button>
|
}
|
||||||
</div>
|
>
|
||||||
</div>
|
{isLegacy
|
||||||
</PopoverContent>
|
? (props.button?.cancel.text ?? "Cancel")
|
||||||
</Popover>
|
: (props.cancelButtonText ?? "Cancel")}
|
||||||
);
|
</Button>
|
||||||
};
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import type React from "react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
export const ErrorLayout = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const url = useSearchParams();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const error = url.get("error");
|
||||||
|
|
||||||
|
if (!error) return;
|
||||||
|
|
||||||
|
const errorMessages: Record<string, string> = {
|
||||||
|
pending: "Your account is not active.",
|
||||||
|
invalid_or_expired_token: "Password reset invalid token.",
|
||||||
|
account_already_linked_to_different_user:
|
||||||
|
"An account already exists with the same email address but different sign-in credentials. Sign in using a provider associated with this email address.",
|
||||||
|
USER_NOT_FOUND: "User not found",
|
||||||
|
FAILED_TO_CREATE_USER: "Failed to create user",
|
||||||
|
FAILED_TO_CREATE_SESSION: "Failed to create session",
|
||||||
|
FAILED_TO_UPDATE_USER: "Failed to update user",
|
||||||
|
FAILED_TO_GET_SESSION: "Failed to get session",
|
||||||
|
INVALID_PASSWORD: "Invalid password",
|
||||||
|
INVALID_EMAIL: "Invalid email",
|
||||||
|
INVALID_EMAIL_OR_PASSWORD: "Invalid email or password",
|
||||||
|
INVALID_USER: "Invalid user",
|
||||||
|
SOCIAL_ACCOUNT_ALREADY_LINKED: "Social account already linked",
|
||||||
|
PROVIDER_NOT_FOUND: "Provider not found",
|
||||||
|
INVALID_TOKEN: "Invalid token",
|
||||||
|
TOKEN_EXPIRED: "Token expired",
|
||||||
|
ID_TOKEN_NOT_SUPPORTED: "id_token not supported",
|
||||||
|
FAILED_TO_GET_USER_INFO: "Failed to get user info",
|
||||||
|
USER_EMAIL_NOT_FOUND: "User email not found",
|
||||||
|
EMAIL_NOT_VERIFIED: "Email not verified",
|
||||||
|
PASSWORD_TOO_SHORT: "Password too short",
|
||||||
|
PASSWORD_TOO_LONG: "Password too long",
|
||||||
|
USER_ALREADY_EXISTS: "User already exists.",
|
||||||
|
USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL:
|
||||||
|
"User already exists. Use another email.",
|
||||||
|
EMAIL_CAN_NOT_BE_UPDATED: "Email can not be updated",
|
||||||
|
CREDENTIAL_ACCOUNT_NOT_FOUND: "Credential account not found",
|
||||||
|
SESSION_EXPIRED:
|
||||||
|
"Session expired. Re-authenticate to perform this action.",
|
||||||
|
FAILED_TO_UNLINK_LAST_ACCOUNT: "You can't unlink your last account",
|
||||||
|
ACCOUNT_NOT_FOUND: "Account not found",
|
||||||
|
USER_ALREADY_HAS_PASSWORD:
|
||||||
|
"User already has a password. Provide that to delete the account.",
|
||||||
|
CROSS_SITE_NAVIGATION_LOGIN_BLOCKED:
|
||||||
|
"Cross-site navigation login blocked. This request appears to be a CSRF attack.",
|
||||||
|
VERIFICATION_EMAIL_NOT_ENABLED: "Verification email isn't enabled",
|
||||||
|
EMAIL_ALREADY_VERIFIED: "Email is already verified",
|
||||||
|
EMAIL_MISMATCH: "Email mismatch",
|
||||||
|
SESSION_NOT_FRESH: "Session is not fresh",
|
||||||
|
LINKED_ACCOUNT_ALREADY_EXISTS: "Linked account already exists",
|
||||||
|
INVALID_ORIGIN: "Invalid origin",
|
||||||
|
INVALID_CALLBACK_URL: "Invalid callbackURL",
|
||||||
|
INVALID_REDIRECT_URL: "Invalid redirectURL",
|
||||||
|
INVALID_ERROR_CALLBACK_URL: "Invalid errorCallbackURL",
|
||||||
|
INVALID_NEW_USER_CALLBACK_URL: "Invalid newUserCallbackURL",
|
||||||
|
MISSING_OR_NULL_ORIGIN: "Missing or null Origin",
|
||||||
|
CALLBACK_URL_REQUIRED: "callbackURL is required",
|
||||||
|
FAILED_TO_CREATE_VERIFICATION: "Unable to create verification",
|
||||||
|
FIELD_NOT_ALLOWED: "Field not allowed to be set",
|
||||||
|
ASYNC_VALIDATION_NOT_SUPPORTED: "Async validation is not supported",
|
||||||
|
VALIDATION_ERROR: "Validation Error",
|
||||||
|
MISSING_FIELD: "Field is required",
|
||||||
|
METHOD_NOT_ALLOWED_DEFER_SESSION_REQUIRED:
|
||||||
|
"POST method requires deferSessionRefresh to be enabled in session config",
|
||||||
|
BODY_MUST_BE_AN_OBJECT: "Body must be an object",
|
||||||
|
PASSWORD_ALREADY_SET: "User already has a password set",
|
||||||
|
Sign_up_is_disabled: "Sign up is disabled by the administrator",
|
||||||
|
};
|
||||||
|
|
||||||
|
const match = Object.entries(errorMessages).find(([key]) =>
|
||||||
|
error.includes(key),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!match) return;
|
||||||
|
|
||||||
|
toast.error(match[1]);
|
||||||
|
|
||||||
|
const params = new URLSearchParams(url.toString());
|
||||||
|
params.delete("error");
|
||||||
|
const newUrl =
|
||||||
|
window.location.pathname +
|
||||||
|
(params.toString() ? `?${params.toString()}` : "");
|
||||||
|
|
||||||
|
window.history.replaceState({}, document.title, newUrl);
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
+55
-12
@@ -1,26 +1,69 @@
|
|||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
|
import {SlackChannelConfigSchema} from "./providers/notifications/forms/slack.schema";
|
||||||
|
import {SmtpChannelConfigSchema} from "./providers/notifications/forms/smtp.schema";
|
||||||
|
import {DiscordChannelConfigSchema} from "./providers/notifications/forms/discord.schema";
|
||||||
|
import {TelegramChannelConfigSchema} from "./providers/notifications/forms/telegram.schema";
|
||||||
|
import {GotifyChannelConfigSchema} from "./providers/notifications/forms/gotify.schema";
|
||||||
|
import {NtfyChannelConfigSchema} from "./providers/notifications/forms/ntfy.schema";
|
||||||
|
import {WebhookChannelConfigSchema} from "./providers/notifications/forms/webhook.schema";
|
||||||
|
import {S3ChannelConfigSchema} from "./providers/storages/forms/s3.schema";
|
||||||
|
import {GoogleDriveChannelConfigSchema} from "./providers/storages/forms/google-drive.schema";
|
||||||
|
import {LocalChannelConfigSchema} from "./providers/storages/forms/local.schema";
|
||||||
|
|
||||||
|
|
||||||
export const ChannelFormSchema = z.object({
|
const BaseChannelFormSchema = z.object({
|
||||||
name: z
|
name: z
|
||||||
.string()
|
.string()
|
||||||
.min(5, "Name must be at least 5 characters long")
|
.min(5, "Name must be at least 5 characters long")
|
||||||
.max(40, "Name must be at most 40 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),
|
enabled: z.boolean().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const NotificationChannelFormSchema = z.discriminatedUnion("provider", [
|
||||||
|
BaseChannelFormSchema.extend({
|
||||||
|
provider: z.literal("slack"),
|
||||||
|
config: SlackChannelConfigSchema,
|
||||||
|
}),
|
||||||
|
BaseChannelFormSchema.extend({
|
||||||
|
provider: z.literal("smtp"),
|
||||||
|
config: SmtpChannelConfigSchema,
|
||||||
|
}),
|
||||||
|
BaseChannelFormSchema.extend({
|
||||||
|
provider: z.literal("discord"),
|
||||||
|
config: DiscordChannelConfigSchema,
|
||||||
|
}),
|
||||||
|
BaseChannelFormSchema.extend({
|
||||||
|
provider: z.literal("telegram"),
|
||||||
|
config: TelegramChannelConfigSchema,
|
||||||
|
}),
|
||||||
|
BaseChannelFormSchema.extend({
|
||||||
|
provider: z.literal("gotify"),
|
||||||
|
config: GotifyChannelConfigSchema,
|
||||||
|
}),
|
||||||
|
BaseChannelFormSchema.extend({
|
||||||
|
provider: z.literal("ntfy"),
|
||||||
|
config: NtfyChannelConfigSchema,
|
||||||
|
}),
|
||||||
|
BaseChannelFormSchema.extend({
|
||||||
|
provider: z.literal("webhook"),
|
||||||
|
config: WebhookChannelConfigSchema,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
export const NotificationChannelFormSchema = ChannelFormSchema.extend({
|
export const StorageChannelFormSchema = z.discriminatedUnion("provider", [
|
||||||
provider: z.enum(
|
BaseChannelFormSchema.extend({
|
||||||
["slack", "smtp", "discord", "telegram", "gotify", "ntfy", "webhook"],
|
provider: z.literal("s3"),
|
||||||
{required_error: "Provider is required"}
|
config: S3ChannelConfigSchema,
|
||||||
),
|
}),
|
||||||
});
|
BaseChannelFormSchema.extend({
|
||||||
|
provider: z.literal("google-drive"),
|
||||||
export const StorageChannelFormSchema = ChannelFormSchema.extend({
|
config: GoogleDriveChannelConfigSchema,
|
||||||
provider: z.enum(["local", "s3", "google-drive"], {required_error: "Provider is required"}),
|
}),
|
||||||
});
|
BaseChannelFormSchema.extend({
|
||||||
|
provider: z.literal("local"),
|
||||||
|
config: LocalChannelConfigSchema
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
|
||||||
export type NotificationChannelFormType = z.infer<typeof NotificationChannelFormSchema>;
|
export type NotificationChannelFormType = z.infer<typeof NotificationChannelFormSchema>;
|
||||||
|
|||||||
+1
-1
@@ -224,7 +224,7 @@ export const ChannelForm = ({onSuccessAction, organization, defaultValues, kind}
|
|||||||
|
|
||||||
<div className="flex justify-between mt-4">
|
<div className="flex justify-between mt-4">
|
||||||
<div>
|
<div>
|
||||||
{kind == "storage" && (
|
{(!isCreate || kind == "storage") && (
|
||||||
<ChannelTestButton
|
<ChannelTestButton
|
||||||
kind={kind}
|
kind={kind}
|
||||||
organizationId={organization?.id}
|
organizationId={organization?.id}
|
||||||
|
|||||||
+3
-2
@@ -34,7 +34,7 @@ export const ChannelTestButton = ({channel, organizationId, kind}: NotifierTestC
|
|||||||
message: `We are testing channel ${channel.name}`,
|
message: `We are testing channel ${channel.name}`,
|
||||||
level: 'info',
|
level: 'info',
|
||||||
};
|
};
|
||||||
const result = await dispatchNotification(payload, undefined, channel, organizationId);
|
const result = await dispatchNotification(payload, undefined, channel.id, organizationId);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
@@ -51,7 +51,8 @@ export const ChannelTestButton = ({channel, organizationId, kind}: NotifierTestC
|
|||||||
toast.success("Successfully connected to storage channel");
|
toast.success("Successfully connected to storage channel");
|
||||||
} else {
|
} else {
|
||||||
console.error(result);
|
console.error(result);
|
||||||
toast.error("An error occurred while testing the storage channel, check your configuration");
|
const responseText = result.response ? `(${result.response})` : "";
|
||||||
|
toast.error(`An error occurred while testing the storage channel, check your configuration ${responseText}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error("Not yet supported");
|
toast.error("Not yet supported");
|
||||||
|
|||||||
+35
-27
@@ -1,30 +1,38 @@
|
|||||||
import {UseFormReturn} from "react-hook-form";
|
import { UseFormReturn } from "react-hook-form";
|
||||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
import {
|
||||||
import {Input} from "@/components/ui/input";
|
FormControl,
|
||||||
import {Separator} from "@/components/ui/separator";
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { PasswordInput } from "@/components/ui/password-input";
|
||||||
|
|
||||||
type NotifierDiscordFormProps = {
|
type NotifierDiscordFormProps = {
|
||||||
form: UseFormReturn<any, any, any>
|
form: UseFormReturn<any, any, any>;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const NotifierDiscordForm = ({form}: NotifierDiscordFormProps) => {
|
export const NotifierDiscordForm = ({ form }: NotifierDiscordFormProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Separator className="my-1"/>
|
<Separator className="my-1" />
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config.discordWebhook"
|
name="config.discordWebhook"
|
||||||
render={({field}) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Discord Webhook URL</FormLabel>
|
<FormLabel>Discord Webhook URL</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="https://discord.com/api/webhooks/..."/>
|
<PasswordInput
|
||||||
</FormControl>
|
{...field}
|
||||||
<FormMessage/>
|
placeholder="https://discord.com/api/webhooks/..."
|
||||||
</FormItem>
|
/>
|
||||||
)}
|
</FormControl>
|
||||||
/>
|
<FormMessage />
|
||||||
</>
|
</FormItem>
|
||||||
)
|
)}
|
||||||
}
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
import {z} from "zod";
|
||||||
|
|
||||||
|
export const DiscordChannelConfigSchema = z.object({
|
||||||
|
discordWebhook: z.string().url("Must be a valid URL"),
|
||||||
|
});
|
||||||
+46
-40
@@ -1,43 +1,49 @@
|
|||||||
import {UseFormReturn} from "react-hook-form";
|
import type { UseFormReturn } from "react-hook-form";
|
||||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
import {
|
||||||
import {Input} from "@/components/ui/input";
|
FormControl,
|
||||||
import {Separator} from "@/components/ui/separator";
|
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";
|
||||||
|
|
||||||
type NotifierGotifyFormProps = {
|
type NotifierGotifyFormProps = {
|
||||||
form: UseFormReturn<any, any, any>
|
form: UseFormReturn<any, any, any>;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const NotifierGotifyForm = ({form}: NotifierGotifyFormProps) => {
|
export const NotifierGotifyForm = ({ form }: NotifierGotifyFormProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Separator className="my-1"/>
|
<Separator className="my-1" />
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config.gotifyServerUrl"
|
name="config.gotifyServerUrl"
|
||||||
render={({field}) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Gotify Server URL</FormLabel>
|
<FormLabel>Gotify Server URL</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="https://gotify.example.com"/>
|
<Input {...field} placeholder="https://gotify.example.com" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage/>
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config.gotifyAppToken"
|
name="config.gotifyAppToken"
|
||||||
render={({field}) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Application Token</FormLabel>
|
<FormLabel>Application Token</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="A1b2C3d4E5f6G7h"/>
|
<PasswordInput {...field} placeholder="A1b2C3d4E5f6G7h" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage/>
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
import {z} from "zod";
|
||||||
|
|
||||||
|
export const GotifyChannelConfigSchema = z.object({
|
||||||
|
gotifyServerUrl: z.string().url("Must be a valid URL"),
|
||||||
|
gotifyAppToken: z.string().min(1, "App token is required"),
|
||||||
|
});
|
||||||
+3
-2
@@ -2,6 +2,7 @@ import {UseFormReturn} from "react-hook-form";
|
|||||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
||||||
import {Input} from "@/components/ui/input";
|
import {Input} from "@/components/ui/input";
|
||||||
import {Separator} from "@/components/ui/separator";
|
import {Separator} from "@/components/ui/separator";
|
||||||
|
import {PasswordInput} from "@/components/ui/password-input";
|
||||||
|
|
||||||
type NotifierNtfyFormProps = {
|
type NotifierNtfyFormProps = {
|
||||||
form: UseFormReturn<any, any, any>
|
form: UseFormReturn<any, any, any>
|
||||||
@@ -48,7 +49,7 @@ export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Access Token (Optional)</FormLabel>
|
<FormLabel>Access Token (Optional)</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="tk_..."/>
|
<PasswordInput {...field} placeholder="tk_..."/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<p className="text-xs text-muted-foreground">Only required for protected topics or self-hosted instances with auth.</p>
|
<p className="text-xs text-muted-foreground">Only required for protected topics or self-hosted instances with auth.</p>
|
||||||
<FormMessage/>
|
<FormMessage/>
|
||||||
@@ -79,7 +80,7 @@ export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Basic Auth Password (Optional)</FormLabel>
|
<FormLabel>Basic Auth Password (Optional)</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} type="password" placeholder="password"/>
|
<PasswordInput placeholder="password" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage/>
|
<FormMessage/>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
import {z} from "zod";
|
||||||
|
|
||||||
|
export const NtfyChannelConfigSchema = z.object({
|
||||||
|
ntfyTopic: z.string().min(1, "Topic is required"),
|
||||||
|
ntfyServerUrl: z.string().url("Must be a valid URL").optional().or(z.literal('')),
|
||||||
|
ntfyToken: z.string().optional(),
|
||||||
|
ntfyUsername: z.string().optional(),
|
||||||
|
ntfyPassword: z.string().optional(),
|
||||||
|
});
|
||||||
+35
-28
@@ -1,31 +1,38 @@
|
|||||||
|
import { UseFormReturn } from "react-hook-form";
|
||||||
import {UseFormReturn} from "react-hook-form";
|
import {
|
||||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
FormControl,
|
||||||
import {Input} from "@/components/ui/input";
|
FormField,
|
||||||
import {Separator} from "@/components/ui/separator";
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { PasswordInput } from "@/components/ui/password-input";
|
||||||
|
|
||||||
type NotifierSmtpFormProps = {
|
type NotifierSmtpFormProps = {
|
||||||
form: UseFormReturn<any, any, any>
|
form: UseFormReturn<any, any, any>;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const NotifierSlackForm = ({form}: NotifierSmtpFormProps) => {
|
export const NotifierSlackForm = ({ form }: NotifierSmtpFormProps) => {
|
||||||
return(
|
return (
|
||||||
<>
|
<>
|
||||||
<Separator className="my-1"/>
|
<Separator className="my-1" />
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config.slackWebhook"
|
name="config.slackWebhook"
|
||||||
render={({field}) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Slack Webhook URL</FormLabel>
|
<FormLabel>Slack Webhook URL</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="https://hooks.slack.com/services/..."/>
|
<PasswordInput
|
||||||
</FormControl>
|
{...field}
|
||||||
<FormMessage/>
|
placeholder="https://hooks.slack.com/services/..."
|
||||||
</FormItem>
|
/>
|
||||||
)}
|
</FormControl>
|
||||||
/>
|
<FormMessage />
|
||||||
</>
|
</FormItem>
|
||||||
)
|
)}
|
||||||
}
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
import {z} from "zod";
|
||||||
|
|
||||||
|
export const SlackChannelConfigSchema = z.object({
|
||||||
|
slackWebhook: z.string().url("Must be a valid URL"),
|
||||||
|
});
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
import {z} from "zod";
|
||||||
|
|
||||||
|
export const SmtpChannelConfigSchema = z.object({
|
||||||
|
host: z.string().min(1, "Host is required"),
|
||||||
|
port: z.coerce.number().min(1, "Port is required"),
|
||||||
|
user: z.string().min(1, "User is required"),
|
||||||
|
password: z.string().min(1, "Password is required"),
|
||||||
|
from: z.string().min(1, "From is required"),
|
||||||
|
to: z.string().min(1, "To is required"),
|
||||||
|
});
|
||||||
+81
-45
@@ -1,48 +1,84 @@
|
|||||||
import {UseFormReturn} from "react-hook-form";
|
import { UseFormReturn } from "react-hook-form";
|
||||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
import {
|
||||||
import {Input} from "@/components/ui/input";
|
FormControl,
|
||||||
import {Separator} from "@/components/ui/separator";
|
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";
|
||||||
|
|
||||||
type NotifierTelegramFormProps = {
|
type NotifierTelegramFormProps = {
|
||||||
form: UseFormReturn<any, any, any>
|
form: UseFormReturn<any, any, any>;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const NotifierTelegramForm = ({form}: NotifierTelegramFormProps) => {
|
export const NotifierTelegramForm = ({ form }: NotifierTelegramFormProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Separator className="my-1"/>
|
<Separator className="my-1" />
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config.telegramBotToken"
|
name="config.telegramBotToken"
|
||||||
render={({field}) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Telegram Bot Token</FormLabel>
|
<FormLabel>Telegram Bot Token</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"/>
|
<PasswordInput
|
||||||
</FormControl>
|
{...field}
|
||||||
<FormMessage/>
|
placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
|
||||||
</FormItem>
|
/>
|
||||||
)}
|
</FormControl>
|
||||||
/>
|
<FormMessage />
|
||||||
<FormField
|
</FormItem>
|
||||||
control={form.control}
|
)}
|
||||||
name="config.telegramChatId"
|
/>
|
||||||
render={({field}) => (
|
<FormField
|
||||||
<FormItem>
|
control={form.control}
|
||||||
<FormLabel>Telegram Chat ID</FormLabel>
|
name="config.telegramChatId"
|
||||||
<FormControl>
|
render={({ field }) => (
|
||||||
<Input {...field} placeholder="-100123456789 or 123456789"/>
|
<FormItem>
|
||||||
</FormControl>
|
<FormLabel>Telegram Chat ID</FormLabel>
|
||||||
<p className="text-xs text-muted-foreground">
|
<FormControl>
|
||||||
You must start the conversation with the bot first (<strong>/start</strong>). <br/>
|
<Input {...field} placeholder="-100123456789 or 123456789" />
|
||||||
For groups, add the bot to the group. <br/>
|
</FormControl>
|
||||||
You can use <a href="https://t.me/userinfobot" target="_blank" rel="noopener noreferrer" className="underline">@userinfobot</a> to find your ID.
|
<p className="text-xs text-muted-foreground">
|
||||||
</p>
|
You must start the conversation with the bot first (
|
||||||
<FormMessage/>
|
<strong>/start</strong>). <br />
|
||||||
</FormItem>
|
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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="config.telegramTopicId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Telegram Topic ID</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="123456" />
|
||||||
|
</FormControl>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Unique identifier for the target message thread (topic) of the
|
||||||
|
forum; for forum supergroups only
|
||||||
|
</p>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const TelegramChannelConfigSchema = z
|
||||||
|
.object({
|
||||||
|
telegramBotToken: z.string().min(1, "Bot token is required"),
|
||||||
|
telegramChatId: z.string().min(1, "Chat ID is required"),
|
||||||
|
telegramTopicId: z.string().optional(),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.telegramTopicId && !data.telegramChatId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Chat ID is required when a Topic ID is provided",
|
||||||
|
path: ["telegramChatId"],
|
||||||
|
},
|
||||||
|
);
|
||||||
+69
-62
@@ -1,65 +1,72 @@
|
|||||||
import {UseFormReturn} from "react-hook-form";
|
import { UseFormReturn } from "react-hook-form";
|
||||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
import {
|
||||||
import {Input} from "@/components/ui/input";
|
FormControl,
|
||||||
import {Separator} from "@/components/ui/separator";
|
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";
|
||||||
|
|
||||||
type NotifierWebhookFormProps = {
|
type NotifierWebhookFormProps = {
|
||||||
form: UseFormReturn<any, any, any>
|
form: UseFormReturn<any, any, any>;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const NotifierWebhookForm = ({form}: NotifierWebhookFormProps) => {
|
export const NotifierWebhookForm = ({ form }: NotifierWebhookFormProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Separator className="my-1"/>
|
<Separator className="my-1" />
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config.webhookUrl"
|
name="config.webhookUrl"
|
||||||
render={({field}) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Webhook URL</FormLabel>
|
<FormLabel>Webhook URL</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="https://example.com/api/webhook"/>
|
<Input {...field} placeholder="https://example.com/api/webhook" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage/>
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config.webhookSecretHeader"
|
name="config.webhookSecretHeader"
|
||||||
render={({field}) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Header Name (Optional)</FormLabel>
|
<FormLabel>Header Name (Optional)</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="X-Webhook-Secret"/>
|
<Input {...field} placeholder="X-Webhook-Secret" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage/>
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config.webhookSecret"
|
name="config.webhookSecret"
|
||||||
render={({field}) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Secret Value (Optional)</FormLabel>
|
<FormLabel>Secret Value (Optional)</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="Secret value..."/>
|
<PasswordInput {...field} placeholder="Secret value..." />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage/>
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
<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>).
|
If provided, the secret will be sent in the specified header (defaults
|
||||||
</p>
|
to <code>X-Webhook-Secret</code>).
|
||||||
</>
|
</p>
|
||||||
)
|
</>
|
||||||
}
|
);
|
||||||
|
};
|
||||||
|
|||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
import {z} from "zod";
|
||||||
|
|
||||||
|
export const WebhookChannelConfigSchema = z.object({
|
||||||
|
webhookUrl: z.string().url("Must be a valid URL"),
|
||||||
|
webhookSecretHeader: z.string().optional(),
|
||||||
|
webhookSecret: z.string().optional(),
|
||||||
|
});
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
import {z} from "zod";
|
||||||
|
|
||||||
|
export const GoogleDriveChannelConfigSchema = z.object({
|
||||||
|
clientId: z.string().min(1, "Client ID is required"),
|
||||||
|
clientSecret: z.string().min(1, "Client Secret is required"),
|
||||||
|
folderId: z.string().min(1, "Folder ID is required"),
|
||||||
|
refreshToken: z.string().optional(),
|
||||||
|
});
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
import {z} from "zod";
|
||||||
|
|
||||||
|
export const LocalChannelConfigSchema = z.object({});
|
||||||
+112
-111
@@ -1,115 +1,116 @@
|
|||||||
import {UseFormReturn} from "react-hook-form";
|
import { UseFormReturn } from "react-hook-form";
|
||||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
import {
|
||||||
import {Input} from "@/components/ui/input";
|
FormControl,
|
||||||
import {Separator} from "@/components/ui/separator";
|
FormField,
|
||||||
import {PasswordInput} from "@/components/ui/password-input";
|
FormItem,
|
||||||
import {Switch} from "@/components/ui/switch";
|
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 = {
|
type StorageS3FormProps = {
|
||||||
form: UseFormReturn<any, any, any>
|
form: UseFormReturn<any, any, any>;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const StorageS3Form = ({form}: StorageS3FormProps) => {
|
export const StorageS3Form = ({ form }: StorageS3FormProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Separator className="my-1"/>
|
<Separator className="my-1" />
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config.endPointUrl"
|
name="config.endPointUrl"
|
||||||
render={({field}) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Endpoint URL</FormLabel>
|
<FormLabel>Endpoint URL</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="s3.amazonaws.com"/>
|
<Input {...field} placeholder="s3.amazonaws.com" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage/>
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config.region"
|
name="config.region"
|
||||||
render={({field}) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Region</FormLabel>
|
<FormLabel>Region</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="us-east-1"/>
|
<Input {...field} placeholder="us-east-1" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage/>
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config.accessKey"
|
name="config.accessKey"
|
||||||
render={({field}) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Access Key</FormLabel>
|
<FormLabel>Access Key</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="A1b2C3d4E5f6G7h"/>
|
<Input {...field} placeholder="A1b2C3d4E5f6G7h" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage/>
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config.secretKey"
|
name="config.secretKey"
|
||||||
render={({field}) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Secret Key</FormLabel>
|
<FormLabel>Secret Key</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<PasswordInput {...field} placeholder="A1b2C3d4E5f6G7h"/>
|
<PasswordInput {...field} placeholder="A1b2C3d4E5f6G7h" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage/>
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config.bucketName"
|
name="config.bucketName"
|
||||||
render={({field}) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Bucket name</FormLabel>
|
<FormLabel>Bucket name</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="portabase-dev"/>
|
<Input {...field} placeholder="portabase-dev" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage/>
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config.port"
|
name="config.port"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Port</FormLabel>
|
<FormLabel>Port</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} type="number" placeholder="443" />
|
<Input {...field} type="number" placeholder="443" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config.ssl"
|
name="config.ssl"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Use SSL</FormLabel>
|
<FormLabel>Use SSL</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Switch
|
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||||
checked={field.value}
|
</FormControl>
|
||||||
onCheckedChange={field.onChange}
|
<FormMessage />
|
||||||
/>
|
</FormItem>
|
||||||
</FormControl>
|
)}
|
||||||
<FormMessage />
|
/>
|
||||||
</FormItem>
|
</>
|
||||||
)}
|
);
|
||||||
/>
|
};
|
||||||
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
import {z} from "zod";
|
||||||
|
|
||||||
|
export const S3ChannelConfigSchema = z.object({
|
||||||
|
endPointUrl: z.string().min(1, "Endpoint URL is required"),
|
||||||
|
region: z.string().optional(),
|
||||||
|
accessKey: z.string().min(1, "Access Key is required"),
|
||||||
|
secretKey: z.string().min(1, "Secret Key is required"),
|
||||||
|
bucketName: z.string().min(1, "Bucket name is required"),
|
||||||
|
port: z.coerce.number().optional(),
|
||||||
|
ssl: z.boolean().optional().default(true),
|
||||||
|
});
|
||||||
@@ -1,60 +1,65 @@
|
|||||||
"use client"
|
"use client";
|
||||||
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
import { NotificationChannelWith } from "@/db/schema/09_notification-channel";
|
||||||
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
|
import { CardsWithPagination } from "@/components/wrappers/common/cards-with-pagination";
|
||||||
import {useState} from "react";
|
import { useState } from "react";
|
||||||
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
|
import { EmptyStatePlaceholder } from "@/components/wrappers/common/empty-state-placeholder";
|
||||||
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
import { OrganizationWithMembers } from "@/db/schema/03_organization";
|
||||||
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
import { StorageChannelWith } from "@/db/schema/12_storage-channel";
|
||||||
import {ChannelCard} from "@/components/wrappers/dashboard/admin/channels/channel/channel-card/channel-card";
|
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 { ChannelAddEditModal } from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal";
|
||||||
import {ChannelKind, getChannelTextBasedOnKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
|
import {
|
||||||
|
ChannelKind,
|
||||||
|
getChannelTextBasedOnKind,
|
||||||
|
} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
|
||||||
|
|
||||||
type ChannelsSectionProps = {
|
type ChannelsSectionProps = {
|
||||||
channels: NotificationChannelWith[] | StorageChannelWith[],
|
channels: NotificationChannelWith[] | StorageChannelWith[];
|
||||||
organizations: OrganizationWithMembers[],
|
organizations: OrganizationWithMembers[];
|
||||||
kind: ChannelKind,
|
kind: ChannelKind;
|
||||||
defaultStorageChannelId?: string | null | undefined
|
defaultStorageChannelId?: string | null | undefined;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const ChannelsSection = ({
|
export const ChannelsSection = ({
|
||||||
organizations,
|
organizations,
|
||||||
channels,
|
channels,
|
||||||
kind,
|
kind,
|
||||||
defaultStorageChannelId
|
defaultStorageChannelId,
|
||||||
}: ChannelsSectionProps) => {
|
}: ChannelsSectionProps) => {
|
||||||
|
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||||
|
const channelText = getChannelTextBasedOnKind(kind);
|
||||||
|
const hasChannels = channels.length > 0;
|
||||||
|
|
||||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
return (
|
||||||
const channelText = getChannelTextBasedOnKind(kind)
|
<div className="h-full">
|
||||||
const hasChannels = channels.length > 0
|
<ChannelAddEditModal
|
||||||
|
kind={kind}
|
||||||
|
open={isAddModalOpen}
|
||||||
return (
|
onOpenChangeAction={setIsAddModalOpen}
|
||||||
|
adminView={false}
|
||||||
|
trigger={false}
|
||||||
|
/>
|
||||||
|
{hasChannels ? (
|
||||||
<div className="h-full">
|
<div className="h-full">
|
||||||
<ChannelAddEditModal kind={kind} open={isAddModalOpen} onOpenChangeAction={setIsAddModalOpen}
|
<CardsWithPagination
|
||||||
adminView={false}
|
data={channels}
|
||||||
trigger={false}/>
|
cardItem={ChannelCard}
|
||||||
{hasChannels ? (
|
cardsPerPage={8}
|
||||||
<div className="h-full">
|
numberOfColumns={2}
|
||||||
<CardsWithPagination
|
adminView={true}
|
||||||
data={channels}
|
organizations={organizations}
|
||||||
cardItem={ChannelCard}
|
kind={kind}
|
||||||
cardsPerPage={8}
|
defaultStorageChannelId={defaultStorageChannelId}
|
||||||
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>
|
</div>
|
||||||
);
|
) : (
|
||||||
}
|
<EmptyStatePlaceholder
|
||||||
|
text={`No ${channelText} channels configured yet`}
|
||||||
|
onClick={() => {
|
||||||
|
setIsAddModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="h-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
+51
-4
@@ -4,7 +4,15 @@ import {Info} from "lucide-react";
|
|||||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||||
import {useRouter} from "next/navigation";
|
import {useRouter} from "next/navigation";
|
||||||
import {Setting} from "@/db/schema/01_setting";
|
import {Setting} from "@/db/schema/01_setting";
|
||||||
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
useZodForm
|
||||||
|
} from "@/components/ui/form";
|
||||||
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
||||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
|
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
|
||||||
import {useMutation} from "@tanstack/react-query";
|
import {useMutation} from "@tanstack/react-query";
|
||||||
@@ -18,6 +26,7 @@ import {
|
|||||||
} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage.action";
|
} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage.action";
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
import {Switch} from "@/components/ui/switch";
|
import {Switch} from "@/components/ui/switch";
|
||||||
|
import {downloadMasterKeyAction} from "@/features/keys/keys.action";
|
||||||
|
|
||||||
export type SettingsStorageSectionProps = {
|
export type SettingsStorageSectionProps = {
|
||||||
settings: Setting;
|
settings: Setting;
|
||||||
@@ -50,6 +59,34 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
const result = await downloadMasterKeyAction();
|
||||||
|
|
||||||
|
if (!result?.success) {
|
||||||
|
toast.error(result?.message ?? "Download failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byteCharacters = atob(result.data as string);
|
||||||
|
const byteNumbers = new Array(byteCharacters.length)
|
||||||
|
.fill(null)
|
||||||
|
.map((_, i) => byteCharacters.charCodeAt(i));
|
||||||
|
|
||||||
|
const blob = new Blob([new Uint8Array(byteNumbers)], {
|
||||||
|
type: "application/octet-stream",
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "master_key.bin";
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<Alert className="mt-3">
|
<Alert className="mt-3">
|
||||||
@@ -59,7 +96,7 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
|
|||||||
The default storage channel will be used by default to store your backups if no storage policy is
|
The default storage channel will be used by default to store your backups if no storage policy is
|
||||||
configured at the database level. </AlertDescription>
|
configured at the database level. </AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
<div className="flex flex-col h-full py-4 ">
|
<div className="flex flex-col h-full py-4 gap-3">
|
||||||
<Form
|
<Form
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
form={form}
|
form={form}
|
||||||
@@ -96,8 +133,6 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="encryption"
|
name="encryption"
|
||||||
@@ -120,6 +155,18 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
|
|||||||
Confirm
|
Confirm
|
||||||
</ButtonWithLoading>
|
</ButtonWithLoading>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-sm font-medium ">
|
||||||
|
Download instance <button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="text-sm hover:text-muted-foreground underline underline-offset-4 transition-colors w-fit"
|
||||||
|
>
|
||||||
|
Master Key
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,75 +1,86 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {useMutation} from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import {User} from "@/db/schema/02_user";
|
import { User } from "@/db/schema/02_user";
|
||||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
|
||||||
import {useRouter} from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogCancel,
|
AlertDialogCancel,
|
||||||
AlertDialogContent,
|
AlertDialogContent,
|
||||||
AlertDialogDescription,
|
AlertDialogDescription,
|
||||||
AlertDialogFooter,
|
AlertDialogFooter,
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import {authClient} from "@/lib/auth/auth-client";
|
import { authClient } from "@/lib/auth/auth-client";
|
||||||
import {toast} from "sonner";
|
import { toast } from "sonner";
|
||||||
import {setSuperAdminOwnerOfOrganizationsOwnedByUser} from "@/components/wrappers/dashboard/admin/users/user.action";
|
import { setSuperAdminOwnerOfOrganizationsOwnedByUser } from "@/components/wrappers/dashboard/admin/users/user.action";
|
||||||
|
|
||||||
type AdminDeleteUserModalProps = {
|
type AdminDeleteUserModalProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
user: User;
|
user: User;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AdminDeleteUserModal = ({user, open, onOpenChange}: AdminDeleteUserModalProps) => {
|
export const AdminDeleteUserModal = ({
|
||||||
const router = useRouter();
|
user,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: AdminDeleteUserModalProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const res = await setSuperAdminOwnerOfOrganizationsOwnedByUser({userId: user.id});
|
const res = await setSuperAdminOwnerOfOrganizationsOwnedByUser({
|
||||||
const result = res?.data;
|
userId: user.id,
|
||||||
if (result?.success) {
|
});
|
||||||
await authClient.admin.removeUser(
|
const result = res?.data;
|
||||||
{
|
if (result?.success) {
|
||||||
userId: user.id,
|
await authClient.admin.removeUser(
|
||||||
},
|
{
|
||||||
{
|
userId: user.id,
|
||||||
onSuccess: async (response) => {
|
},
|
||||||
toast.success(`User ${user.name} successfully deleted`);
|
{
|
||||||
onOpenChange(false);
|
onSuccess: async () => {
|
||||||
router.refresh();
|
toast.success(`User ${user.name} successfully deleted`);
|
||||||
},
|
onOpenChange(false);
|
||||||
onError: async (error) => {
|
router.refresh();
|
||||||
toast.error("An error has occurred while deleting user");
|
},
|
||||||
onOpenChange(false);
|
onError: async (error) => {
|
||||||
},
|
toast.error("An error has occurred while deleting user");
|
||||||
}
|
onOpenChange(false);
|
||||||
);
|
},
|
||||||
} else {
|
},
|
||||||
toast.error("An error has occurred while deleting user");
|
);
|
||||||
}
|
} else {
|
||||||
},
|
toast.error("An error has occurred while deleting user");
|
||||||
onError: (error) => {
|
}
|
||||||
toast.error(error.message);
|
},
|
||||||
},
|
onError: (error) => {
|
||||||
});
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Are you sure you want to delete {user.name} ?</AlertDialogTitle>
|
<AlertDialogTitle>
|
||||||
<AlertDialogDescription>This action is irreversible: it will lead to the deletion of the user's
|
Are you sure you want to delete {user.name} ?
|
||||||
data.</AlertDialogDescription>
|
</AlertDialogTitle>
|
||||||
</AlertDialogHeader>
|
<AlertDialogDescription>
|
||||||
<AlertDialogFooter>
|
This action is irreversible: it will lead to the deletion of the
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
user's data.
|
||||||
<ButtonWithLoading
|
</AlertDialogDescription>
|
||||||
onClick={async () => await mutation.mutateAsync()}>Confirm</ButtonWithLoading>
|
</AlertDialogHeader>
|
||||||
</AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
</AlertDialog>
|
<ButtonWithLoading onClick={async () => await mutation.mutateAsync()}>
|
||||||
);
|
Confirm
|
||||||
|
</ButtonWithLoading>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import {User} from "@/db/schema/02_user";
|
|||||||
|
|
||||||
type AdminUserListProps = {
|
type AdminUserListProps = {
|
||||||
users: User[];
|
users: User[];
|
||||||
|
isPasswordAuthEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AdminUserList = ({ users }: AdminUserListProps) => {
|
export const AdminUserList = ({ users, isPasswordAuthEnabled }: AdminUserListProps) => {
|
||||||
return <DataTable columns={usersListColumns()} data={users} enablePagination={true} enableSelect={false} />;
|
return <DataTable columns={usersListColumns({ isPasswordAuthEnabled })} data={users} enablePagination={true} enableSelect={false} />;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ import {Info} from "lucide-react";
|
|||||||
import {Table, TableBody, TableCell, TableRow} from "@/components/ui/table";
|
import {Table, TableBody, TableCell, TableRow} from "@/components/ui/table";
|
||||||
import {UserActionsCell} from "@/components/wrappers/dashboard/admin/users/user-actions-cell";
|
import {UserActionsCell} from "@/components/wrappers/dashboard/admin/users/user-actions-cell";
|
||||||
|
|
||||||
export function usersListColumns(): ColumnDef<User>[] {
|
type UsersListColumnsProps = {
|
||||||
|
isPasswordAuthEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usersListColumns({ isPasswordAuthEnabled }: UsersListColumnsProps): ColumnDef<User>[] {
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -85,7 +89,7 @@ export function usersListColumns(): ColumnDef<User>[] {
|
|||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
header: "Actions",
|
header: "Actions",
|
||||||
cell: ({row}) => <UserActionsCell user={row.original}/>,
|
cell: ({row}) => <UserActionsCell user={row.original} isPasswordAuthEnabled={isPasswordAuthEnabled} />,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ import {AdminDeleteUserModal} from "@/components/wrappers/dashboard/admin/users/
|
|||||||
|
|
||||||
interface UserActionsCellProps {
|
interface UserActionsCellProps {
|
||||||
user: User;
|
user: User;
|
||||||
|
isPasswordAuthEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserActionsCell({user}: UserActionsCellProps) {
|
export function UserActionsCell({user, isPasswordAuthEnabled}: UserActionsCellProps) {
|
||||||
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [isModalChangePasswordOpen, setIsModalChangePasswordOpen] = useState(false);
|
const [isModalChangePasswordOpen, setIsModalChangePasswordOpen] = useState(false);
|
||||||
@@ -43,9 +44,11 @@ export function UserActionsCell({user}: UserActionsCellProps) {
|
|||||||
onOpenChange={setIsModalChangePasswordOpen}/>
|
onOpenChange={setIsModalChangePasswordOpen}/>
|
||||||
<AdminUserEdit user={user} open={isModalEditUserOpen} onOpenChange={setIsModalEditUserOpen}/>
|
<AdminUserEdit user={user} open={isModalEditUserOpen} onOpenChange={setIsModalEditUserOpen}/>
|
||||||
<div className={cn("flex items-center space-x-2")}>
|
<div className={cn("flex items-center space-x-2")}>
|
||||||
<Button variant="outline" size="icon" onClick={() => setIsModalChangePasswordOpen(true)}>
|
{isPasswordAuthEnabled && (
|
||||||
<RotateCcwKey className="w-4 h-4"/>
|
<Button variant="outline" size="icon" onClick={() => setIsModalChangePasswordOpen(true)}>
|
||||||
</Button>
|
<RotateCcwKey className="w-4 h-4"/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon">
|
<Button variant="ghost" size="icon">
|
||||||
|
|||||||
@@ -1,191 +1,211 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {ServerActionResult} from "@/types/action-type";
|
import { ServerActionResult } from "@/types/action-type";
|
||||||
import {render} from "@react-email/render";
|
import { render } from "@react-email/render";
|
||||||
import {UserSchema} from "@/components/wrappers/dashboard/admin/users/user.schema";
|
import { UserSchema } from "@/components/wrappers/dashboard/admin/users/user.schema";
|
||||||
import {extractNameFromEmail} from "@/utils/name-from-email";
|
import { extractNameFromEmail } from "@/utils/name-from-email";
|
||||||
import {generateValidPassword} from "@/utils/password";
|
import { generateValidPassword } from "@/utils/password";
|
||||||
import {auth} from "@/lib/auth/auth";
|
import { auth } from "@/lib/auth/auth";
|
||||||
import {z} from "zod";
|
import { z } from "zod";
|
||||||
import {Organization} from "@/db/schema/03_organization";
|
import { Organization } from "@/db/schema/03_organization";
|
||||||
import {db} from "@/db";
|
import { db } from "@/db";
|
||||||
import {and, eq} from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
|
|
||||||
import {zEmail, zString} from "@/lib/zod";
|
import { zEmail, zString } from "@/lib/zod";
|
||||||
import {withUpdatedAt} from "@/db/utils";
|
import { withUpdatedAt } from "@/db/utils";
|
||||||
import {userAction} from "@/lib/safe-actions/actions";
|
import { userAction } from "@/lib/safe-actions/actions";
|
||||||
import {
|
import { addMemberOrganizationAction } from "@/components/wrappers/dashboard/admin/organizations/organization/details/add-member.action";
|
||||||
addMemberOrganizationAction
|
import { sendEmail } from "@/lib/email";
|
||||||
} from "@/components/wrappers/dashboard/admin/organizations/organization/details/add-member.action";
|
|
||||||
import {sendEmail} from "@/lib/email";
|
|
||||||
import EmailCreateUser from "@/components/emails/email-create-user";
|
import EmailCreateUser from "@/components/emails/email-create-user";
|
||||||
import {SignUpUser} from "@/types/auth";
|
import { SignUpUser } from "@/types/auth";
|
||||||
import {createUserDb} from "@/db/services/user";
|
import { createUserDb } from "@/db/services/user";
|
||||||
import {User} from "@/db/schema/02_user";
|
import { User } from "@/db/schema/02_user";
|
||||||
|
import { env } from "@/env.mjs";
|
||||||
|
|
||||||
export const createUserAction = userAction.schema(UserSchema).action(async ({parsedInput}): Promise<ServerActionResult<User>> => {
|
export const createUserAction = userAction
|
||||||
|
.schema(UserSchema)
|
||||||
|
.action(async ({ parsedInput }): Promise<ServerActionResult<User>> => {
|
||||||
try {
|
try {
|
||||||
const password = generateValidPassword();
|
const isPasswordAuthEnabled = env.AUTH_EMAIL_PASSWORD_ENABLED === "true";
|
||||||
|
let password;
|
||||||
|
|
||||||
const userData: SignUpUser = {
|
const userData: SignUpUser = {
|
||||||
name: parsedInput.name || extractNameFromEmail(parsedInput.email),
|
name: parsedInput.name || extractNameFromEmail(parsedInput.email),
|
||||||
email: parsedInput.email,
|
email: parsedInput.email,
|
||||||
password: password,
|
theme: "dark",
|
||||||
theme: "dark",
|
role: "user",
|
||||||
role: "user",
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const newUser = await createUserDb(userData);
|
if (isPasswordAuthEnabled) {
|
||||||
|
password = generateValidPassword();
|
||||||
|
userData.password = password;
|
||||||
|
}
|
||||||
|
|
||||||
if (newUser) {
|
const newUser = await createUserDb(userData);
|
||||||
|
|
||||||
await sendEmail({
|
if (newUser) {
|
||||||
to: parsedInput.email,
|
await sendEmail({
|
||||||
subject: "Your account is created",
|
to: parsedInput.email,
|
||||||
html: await render(EmailCreateUser({
|
subject: "Your account is created",
|
||||||
password: password,
|
html: await render(
|
||||||
email: parsedInput.email,
|
EmailCreateUser({
|
||||||
})),
|
password: password,
|
||||||
});
|
email: parsedInput.email,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
const defaultOrganization = await db.query.organization.findFirst({
|
const defaultOrganization = await db.query.organization.findFirst({
|
||||||
where: eq(drizzleDb.schemas.organization.slug, "default"),
|
where: eq(drizzleDb.schemas.organization.slug, "default"),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (defaultOrganization) {
|
if (defaultOrganization) {
|
||||||
await auth.api.addMember({
|
await auth.api.addMember({
|
||||||
body: {
|
body: {
|
||||||
userId: newUser.id,
|
userId: newUser.id,
|
||||||
organizationId: defaultOrganization.id,
|
organizationId: defaultOrganization.id,
|
||||||
role: "admin",
|
role: "admin",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
value: newUser,
|
|
||||||
actionSuccess: {
|
|
||||||
message: "user_created",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: true,
|
||||||
actionError: {
|
value: newUser,
|
||||||
message: "user_created",
|
actionSuccess: {
|
||||||
cause: "Unknown error",
|
message: "user_created",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
actionError: {
|
||||||
|
message: "user_created",
|
||||||
|
cause: "Unknown error",
|
||||||
|
},
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
actionError: {
|
actionError: {
|
||||||
message: "user_created",
|
message: "user_created",
|
||||||
cause: error instanceof Error ? error.message : "Unknown error",
|
cause: error instanceof Error ? error.message : "Unknown error",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateUserAction = userAction
|
export const updateUserAction = userAction
|
||||||
.schema(
|
.schema(
|
||||||
z.object({
|
z.object({
|
||||||
id: zString(),
|
id: zString(),
|
||||||
name: zString().optional(),
|
name: zString().optional(),
|
||||||
email: zEmail(),
|
email: zEmail(),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.action(async ({parsedInput, ctx}): Promise<ServerActionResult<{}>> => {
|
.action(async ({ parsedInput, ctx }): Promise<ServerActionResult<{}>> => {
|
||||||
try {
|
try {
|
||||||
|
const [updatedUser] = await db
|
||||||
|
.update(drizzleDb.schemas.user)
|
||||||
|
.set(
|
||||||
|
withUpdatedAt({
|
||||||
|
name: parsedInput.name
|
||||||
|
? parsedInput.name
|
||||||
|
: extractNameFromEmail(parsedInput.email),
|
||||||
|
email: parsedInput.email,
|
||||||
|
emailVerified: false,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.where(eq(drizzleDb.schemas.user.id, parsedInput.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (updatedUser) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
actionSuccess: {
|
||||||
|
message: "user_updated",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const [updatedUser] = await db.update(drizzleDb.schemas.user).set(withUpdatedAt({
|
return {
|
||||||
name: parsedInput.name ? parsedInput.name : extractNameFromEmail(parsedInput.email),
|
success: false,
|
||||||
email: parsedInput.email,
|
actionError: {
|
||||||
emailVerified: false
|
message: "user_updated",
|
||||||
})).where(eq(drizzleDb.schemas.user.id, parsedInput.id)).returning();
|
cause: "Unknown error",
|
||||||
|
},
|
||||||
|
};
|
||||||
if (updatedUser) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: false,
|
||||||
actionSuccess: {
|
actionError: {
|
||||||
message: "user_updated",
|
message: "user_updated",
|
||||||
},
|
cause: error instanceof Error ? error.message : "Unknown error",
|
||||||
};
|
},
|
||||||
}
|
};
|
||||||
|
}
|
||||||
return {
|
});
|
||||||
success: false,
|
|
||||||
actionError: {
|
|
||||||
message: "user_updated",
|
|
||||||
cause: "Unknown error",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
actionError: {
|
|
||||||
message: "user_updated",
|
|
||||||
cause: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export const setSuperAdminOwnerOfOrganizationsOwnedByUser = userAction
|
export const setSuperAdminOwnerOfOrganizationsOwnedByUser = userAction
|
||||||
.schema(
|
.schema(
|
||||||
z.object({
|
z.object({
|
||||||
userId: z.string(),
|
userId: z.string(),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.action(async ({parsedInput}): Promise<ServerActionResult<Organization[]>> => {
|
.action(
|
||||||
try {
|
async ({ parsedInput }): Promise<ServerActionResult<Organization[]>> => {
|
||||||
const organizationsWhereUserIsMemberAndOwner = await db.query.member.findMany({
|
try {
|
||||||
where: and(eq(drizzleDb.schemas.member.role, "owner"), eq(drizzleDb.schemas.member.userId, parsedInput.userId)),
|
const organizationsWhereUserIsMemberAndOwner =
|
||||||
with: {
|
await db.query.member.findMany({
|
||||||
organization: true,
|
where: and(
|
||||||
},
|
eq(drizzleDb.schemas.member.role, "owner"),
|
||||||
});
|
eq(drizzleDb.schemas.member.userId, parsedInput.userId),
|
||||||
|
),
|
||||||
|
with: {
|
||||||
|
organization: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const superAdminUser = await db.query.user.findFirst();
|
const superAdminUser = await db.query.user.findFirst();
|
||||||
if (!superAdminUser) {
|
if (!superAdminUser) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
actionError: {
|
actionError: {
|
||||||
message: "set_super_admin_owner_of_organizations_owned_by_user",
|
message: "set_super_admin_owner_of_organizations_owned_by_user",
|
||||||
cause: "Unknown error",
|
cause: "Unknown error",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
for (let {organization} of organizationsWhereUserIsMemberAndOwner) {
|
|
||||||
await addMemberOrganizationAction({
|
|
||||||
userId: superAdminUser.id,
|
|
||||||
organizationId: organization.id,
|
|
||||||
role: "owner",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const organizations = organizationsWhereUserIsMemberAndOwner.map(
|
|
||||||
(organizationWhereUserIsMemberAndOwner) => organizationWhereUserIsMemberAndOwner.organization
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
value: organizations as unknown as Organization[],
|
|
||||||
actionSuccess: {
|
|
||||||
message: "set_super_admin_owner_of_organizations_owned_by_user",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
actionError: {
|
|
||||||
message: "error_set_super_admin_owner_of_organizations_owned_by_user",
|
|
||||||
cause: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
for (let { organization } of organizationsWhereUserIsMemberAndOwner) {
|
||||||
|
await addMemberOrganizationAction({
|
||||||
|
userId: superAdminUser.id,
|
||||||
|
organizationId: organization.id,
|
||||||
|
role: "owner",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const organizations = organizationsWhereUserIsMemberAndOwner.map(
|
||||||
|
(organizationWhereUserIsMemberAndOwner) =>
|
||||||
|
organizationWhereUserIsMemberAndOwner.organization,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
value: organizations as unknown as Organization[],
|
||||||
|
actionSuccess: {
|
||||||
|
message: "set_super_admin_owner_of_organizations_owned_by_user",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
actionError: {
|
||||||
|
message:
|
||||||
|
"error_set_super_admin_owner_of_organizations_owned_by_user",
|
||||||
|
cause: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,49 +1,72 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {useMutation, useQueryClient} from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import {useRouter} from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {toast} from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import {backupButtonAction} from "@/components/wrappers/dashboard/backup/backup-button/backup-button.action";
|
import { backupButtonAction } from "@/components/wrappers/dashboard/backup/backup-button/backup-button.action";
|
||||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
import { Check, DatabaseZap, X } from "lucide-react";
|
||||||
import {Database, DatabaseZap} from "lucide-react";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import {useIsMobile} from "@/hooks/use-mobile";
|
import { ButtonWithConfirm } from "@/components/wrappers/common/button/button-with-confirm";
|
||||||
|
|
||||||
export type BackupButtonProps = {
|
export type BackupButtonProps = {
|
||||||
databaseId: string;
|
databaseId: string;
|
||||||
disable: boolean;
|
disable: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BackupButton = (props: BackupButtonProps) => {
|
export const BackupButton = (props: BackupButtonProps) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async (databaseId: string) => {
|
mutationFn: async (databaseId: string) => {
|
||||||
const backup = await backupButtonAction(databaseId);
|
const backup = await backupButtonAction(databaseId);
|
||||||
if (backup?.data?.success) {
|
if (backup?.data?.success) {
|
||||||
toast.success(backup.data.actionSuccess?.message || "Backup created successfully!");
|
toast.success(
|
||||||
queryClient.invalidateQueries({queryKey: ["database-data", props.databaseId]});
|
backup.data.actionSuccess?.message || "Backup created successfully!",
|
||||||
router.refresh();
|
);
|
||||||
} else {
|
queryClient.invalidateQueries({
|
||||||
toast.error(backup?.serverError || "Failed to create backup.");
|
queryKey: ["database-data", props.databaseId],
|
||||||
}
|
});
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
toast.error(backup?.serverError || "Failed to create backup.");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const HandleAction = async () => {
|
||||||
|
await mutation.mutateAsync(props.databaseId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ButtonWithConfirm
|
||||||
|
title="Create Backup"
|
||||||
|
description={"Are you sure you want to create a backup?"}
|
||||||
|
button={{
|
||||||
|
main: {
|
||||||
|
disabled: props.disable,
|
||||||
|
text: isMobile ? "" : "Backup",
|
||||||
|
variant: "default",
|
||||||
|
icon: <DatabaseZap />,
|
||||||
},
|
},
|
||||||
});
|
confirm: {
|
||||||
const HandleAction = async () => {
|
className: "w-full",
|
||||||
await mutation.mutateAsync(props.databaseId);
|
text: "Yes, create backup",
|
||||||
};
|
icon: <Check />,
|
||||||
|
variant: "default",
|
||||||
return (
|
onClick: async () => {
|
||||||
<ButtonWithLoading
|
await HandleAction();
|
||||||
icon={<DatabaseZap/>}
|
},
|
||||||
disabled={props.disable}
|
},
|
||||||
isPending={mutation.isPending}
|
cancel: {
|
||||||
size={"default"}
|
className: "w-full",
|
||||||
onClick={async () => {
|
text: "No, cancel",
|
||||||
await HandleAction();
|
icon: <X />,
|
||||||
}}
|
variant: "outline",
|
||||||
>{isMobile ? "" : "Backup"}</ButtonWithLoading>
|
},
|
||||||
);
|
}}
|
||||||
|
isPending={mutation.isPending}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import {currentUser} from "@/lib/auth/current-user";
|
import { currentUser } from "@/lib/auth/current-user";
|
||||||
import {getAccounts, getSession, getSessions} from "@/lib/auth/auth";
|
import { getAccounts, getSession, getSessions } from "@/lib/auth/auth";
|
||||||
import {LoggedInButtonClient} from "./logged-in-button";
|
import { LoggedInButtonClient } from "./logged-in-button";
|
||||||
import {SUPPORTED_PROVIDERS} from "../../../../../../portabase.config";
|
import { SUPPORTED_PROVIDERS } from "@/lib/auth/config";
|
||||||
|
|
||||||
|
|
||||||
export const LoggedInButton = async () => {
|
export const LoggedInButton = async () => {
|
||||||
const user = await currentUser();
|
const user = await currentUser();
|
||||||
@@ -10,7 +9,6 @@ export const LoggedInButton = async () => {
|
|||||||
const currentSession = await getSession();
|
const currentSession = await getSession();
|
||||||
const accounts = await getAccounts();
|
const accounts = await getAccounts();
|
||||||
|
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,29 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {ChevronsUpDown} from "lucide-react";
|
import { ChevronsUpDown } from "lucide-react";
|
||||||
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import {SidebarMenuButton} from "@/components/ui/sidebar";
|
import { SidebarMenuButton } from "@/components/ui/sidebar";
|
||||||
import {LoggedInDropdown} from "./logged-in-dropdown";
|
import { LoggedInDropdown } from "./logged-in-dropdown";
|
||||||
import {Account, Session, User} from "better-auth";
|
import { Account, Session, User } from "better-auth";
|
||||||
import {AuthProviderConfig} from "../../../../../../portabase.config";
|
import { AuthProviderConfig } from "@/lib/auth/config";
|
||||||
|
|
||||||
type LoggedInButtonClientProps = {
|
type LoggedInButtonClientProps = {
|
||||||
user: User,
|
user: User;
|
||||||
sessions: Session[],
|
sessions: Session[];
|
||||||
currentSession: Session,
|
currentSession: Session;
|
||||||
accounts: Account[],
|
accounts: Account[];
|
||||||
providers: AuthProviderConfig[]
|
providers: AuthProviderConfig[];
|
||||||
}
|
};
|
||||||
|
|
||||||
|
|
||||||
export const LoggedInButtonClient = ({
|
|
||||||
user,
|
|
||||||
sessions,
|
|
||||||
currentSession,
|
|
||||||
accounts,
|
|
||||||
providers
|
|
||||||
}: LoggedInButtonClientProps) => {
|
|
||||||
|
|
||||||
|
export const LoggedInButtonClient = ({ user, sessions, currentSession, accounts, providers }: LoggedInButtonClientProps) => {
|
||||||
return (
|
return (
|
||||||
<LoggedInDropdown
|
<LoggedInDropdown
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -40,20 +32,16 @@ export const LoggedInButtonClient = ({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Avatar className="size-6">
|
<Avatar className="size-6">
|
||||||
<AvatarFallback>{user.name[0].toUpperCase()}</AvatarFallback>
|
<AvatarFallback>{user.name[0].toUpperCase()}</AvatarFallback>
|
||||||
{user.image && <AvatarImage src={user.image}/>}
|
{user.image && <AvatarImage src={user.image} />}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex flex-col items-start">
|
<div className="flex flex-col items-start">
|
||||||
<span
|
<span className="text-sm font-medium first-letter:capitalize max-w-[170px] truncate">{user.name}</span>
|
||||||
className="text-sm font-medium first-letter:capitalize max-w-[170px] truncate">{user.name}</span>
|
<span className="text-xs text-muted-foreground max-w-[170px] truncate" title={user.email}>
|
||||||
<span
|
{user.email}
|
||||||
className="text-xs text-muted-foreground max-w-[170px] truncate"
|
</span>
|
||||||
title={user.email}
|
|
||||||
>
|
|
||||||
{user.email}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50"/>
|
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</LoggedInDropdown>
|
</LoggedInDropdown>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {PropsWithChildren, ReactNode, useState} from "react";
|
import { PropsWithChildren, ReactNode, useState } from "react";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
@@ -10,11 +10,11 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSepara
|
|||||||
|
|
||||||
import { signOut } from "@/lib/auth/auth-client";
|
import { signOut } from "@/lib/auth/auth-client";
|
||||||
|
|
||||||
import {ProfileModal} from "@/components/wrappers/dashboard/common/profile/profile-modal";
|
import { ProfileModal } from "@/components/wrappers/dashboard/common/profile/profile-modal";
|
||||||
|
|
||||||
import {Account, Session, User as UserType} from "@/db/schema/02_user";
|
import { Account, Session, User as UserType } from "@/db/schema/02_user";
|
||||||
|
|
||||||
import {AuthProviderConfig} from "../../../../../../portabase.config";
|
import { AuthProviderConfig } from "@/lib/auth/config";
|
||||||
|
|
||||||
export type LoggedInDropdownProps = PropsWithChildren<{
|
export type LoggedInDropdownProps = PropsWithChildren<{
|
||||||
user: UserType;
|
user: UserType;
|
||||||
@@ -22,13 +22,10 @@ export type LoggedInDropdownProps = PropsWithChildren<{
|
|||||||
currentSession: Session;
|
currentSession: Session;
|
||||||
accounts: Account[];
|
accounts: Account[];
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
providers: AuthProviderConfig[]
|
providers: AuthProviderConfig[];
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, children, providers }: LoggedInDropdownProps) => {
|
export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, children, providers }: LoggedInDropdownProps) => {
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
@@ -44,50 +41,46 @@ export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, chi
|
|||||||
onOpenChange={setIsModalOpen}
|
onOpenChange={setIsModalOpen}
|
||||||
providers={providers}
|
providers={providers}
|
||||||
/>
|
/>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
className="w-[var(--radix-popper-anchor-width)] rounded-xl border-2 border-border bg-popover shadow-none p-1"
|
className="w-[var(--radix-popper-anchor-width)] rounded-xl border-2 border-border bg-popover shadow-none p-1"
|
||||||
align="start"
|
align="start"
|
||||||
side="top"
|
side="top"
|
||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
>
|
>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => setIsModalOpen(!isModalOpen)}
|
onClick={() => setIsModalOpen(!isModalOpen)}
|
||||||
className="group gap-2 p-1 cursor-pointer rounded-lg mb-1 transition-colors focus:bg-accent hover:bg-accent/50 border border-transparent"
|
className="group gap-2 p-1 cursor-pointer rounded-lg mb-1 transition-colors focus:bg-accent hover:bg-accent/50 border border-transparent"
|
||||||
>
|
>
|
||||||
<div className="flex size-9 items-center justify-center rounded-md border border-border bg-muted/50 shadow-sm transition-all group-hover:shadow-md group-hover:bg-background">
|
<div className="flex size-9 items-center justify-center rounded-md border border-border bg-muted/50 shadow-sm transition-all group-hover:shadow-md group-hover:bg-background">
|
||||||
<User size={18} className="text-muted-foreground group-hover:text-foreground transition-colors" />
|
<User size={18} className="text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-sm font-medium leading-none">Account Settings</span>
|
<span className="text-sm font-medium leading-none">Account Settings</span>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="group gap-2 p-1 cursor-pointer rounded-lg transition-colors focus:bg-red-50 dark:focus:bg-red-950/20 border border-transparent text-red-600 focus:text-red-600"
|
className="group gap-2 p-1 cursor-pointer rounded-lg transition-colors focus:bg-red-50 dark:focus:bg-red-950/20 border border-transparent text-red-600 focus:text-red-600"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await signOut({
|
await signOut({
|
||||||
fetchOptions: {
|
fetchOptions: {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
router.push("/login");
|
router.push("/login");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex size-9 items-center justify-center rounded-md border border-red-100 bg-red-50/50 dark:border-red-900/30 dark:bg-red-950/20 shadow-sm transition-all group-hover:shadow-md">
|
<div className="flex size-9 items-center justify-center rounded-md border border-red-100 bg-red-50/50 dark:border-red-900/30 dark:bg-red-950/20 shadow-sm transition-all group-hover:shadow-md">
|
||||||
<LogOut size={18} className="text-red-500" />
|
<LogOut size={18} className="text-red-500" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-sm font-medium leading-none">Logout</span>
|
<span className="text-sm font-medium leading-none">Logout</span>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</>
|
</>
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||||
import { Account, Session, User } from "@/db/schema/02_user";
|
|
||||||
import { ProfileSidebar } from "./profile-sidebar";
|
import { ProfileSidebar } from "./profile-sidebar";
|
||||||
import {ProfileProviders} from "@/components/wrappers/dashboard/profile/profile-providers";
|
import type { AuthProviderConfig } from "@/lib/auth/config";
|
||||||
import {ProfileAccount} from "@/components/wrappers/dashboard/profile/profile-account";
|
import { User, Session, Account } from "@/db/schema/02_user";
|
||||||
import {ProfileAppearance} from "@/components/wrappers/dashboard/profile/profile-apperance";
|
import { ProfileGeneral } from "../../profile/profile-general";
|
||||||
import {ProfileSecurity} from "@/components/wrappers/dashboard/profile/profile-security";
|
import { ProfileSecurity } from "../../profile/profile-security";
|
||||||
import {ProfileGeneral} from "@/components/wrappers/dashboard/profile/profile-general";
|
import { ProfileProviders } from "../../profile/profile-providers";
|
||||||
import {AuthProviderConfig} from "../../../../../../portabase.config";
|
import { ProfileAccount } from "../../profile/profile-account";
|
||||||
|
import { ProfileAppearance } from "../../profile/profile-apperance";
|
||||||
|
|
||||||
type ProfileModalProps = {
|
type ProfileModalProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -19,14 +18,13 @@ type ProfileModalProps = {
|
|||||||
currentSession: Session;
|
currentSession: Session;
|
||||||
accounts: Account[];
|
accounts: Account[];
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
providers: AuthProviderConfig[]
|
providers: AuthProviderConfig[];
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ProfileModal = ({ user, sessions, currentSession, accounts, open, onOpenChange, providers }: ProfileModalProps) => {
|
export const ProfileModal = ({ user, sessions, currentSession, accounts, open, onOpenChange, providers }: ProfileModalProps) => {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="w-[95vw] h-[90vh] max-w-md lg:max-w-[1000px] lg:h-[800px] pb-6 overflow-hidden flex flex-col outline-none gap-0 rounded-xl bg-background">
|
<DialogContent className="w-[95vw] h-[90vh] max-w-md lg:max-w-[1000px] lg:h-[800px] pb-6 p-0 overflow-hidden flex flex-col outline-none gap-0 rounded-xl bg-background">
|
||||||
<DialogHeader className="sr-only">
|
<DialogHeader className="sr-only">
|
||||||
<DialogTitle>Settings</DialogTitle>
|
<DialogTitle>Settings</DialogTitle>
|
||||||
<DialogDescription>Manage your account settings</DialogDescription>
|
<DialogDescription>Manage your account settings</DialogDescription>
|
||||||
@@ -45,6 +43,9 @@ export const ProfileModal = ({ user, sessions, currentSession, accounts, open, o
|
|||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
currentSession={currentSession}
|
currentSession={currentSession}
|
||||||
credentialAccount={accounts.find((acc) => acc.providerId === "credential")!}
|
credentialAccount={accounts.find((acc) => acc.providerId === "credential")!}
|
||||||
|
isPasswordEnabled={providers.some((p) => p.id === "credential")}
|
||||||
|
isPasskeyEnabled={providers.some((p) => p.id === "passkey")}
|
||||||
|
providers={providers}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { use } from "react";
|
import React from "react";
|
||||||
import { TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { UserIcon, Settings, Palette, ShieldHalf, Workflow } from "lucide-react";
|
import { UserIcon, Settings, Palette, ShieldHalf, Workflow } from "lucide-react";
|
||||||
import { User } from "@/db/schema/02_user";
|
import { User } from "@/db/schema/02_user";
|
||||||
@@ -10,7 +10,6 @@ interface ProfileSidebarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ProfileSidebar({ user }: ProfileSidebarProps) {
|
export function ProfileSidebar({ user }: ProfileSidebarProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full lg:w-[260px] flex-shrink-0 lg:border-r bg-muted/10 p-4 lg:p-6 flex flex-col gap-4 border-b lg:border-b-0">
|
<div className="w-full lg:w-[260px] flex-shrink-0 lg:border-r bg-muted/10 p-4 lg:p-6 flex flex-col gap-4 border-b lg:border-b-0">
|
||||||
<div className="flex items-center px-2 mb-2">
|
<div className="flex items-center px-2 mb-2">
|
||||||
|
|||||||
+272
-231
@@ -1,253 +1,294 @@
|
|||||||
"use client"
|
"use client";
|
||||||
import {Backup, BackupWith, Restoration} from "@/db/schema/07_database";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import {Swiper, SwiperSlide} from "swiper/react";
|
import { Badge } from "@/components/ui/badge";
|
||||||
//@ts-ignore
|
|
||||||
import "swiper/css";
|
|
||||||
import "swiper/css/pagination";
|
|
||||||
import {Pagination, Mousewheel} from "swiper/modules";
|
|
||||||
import {DatabaseActionKind, useBackupModal} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
|
|
||||||
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
|
|
||||||
import {
|
import {
|
||||||
BackupActionsSchema,
|
Form,
|
||||||
BackupActionsType
|
FormControl,
|
||||||
} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.schema";
|
FormField,
|
||||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
FormItem,
|
||||||
import {useMutation, useQueryClient} from "@tanstack/react-query";
|
FormLabel,
|
||||||
import {BackupStorageWith} from "@/db/schema/14_storage-backup";
|
FormMessage,
|
||||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
useZodForm,
|
||||||
import {getChannelIcon} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
|
} from "@/components/ui/form";
|
||||||
import {Badge} from "@/components/ui/badge";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import {getStatusColor, getStatusIcon} from "@/components/wrappers/dashboard/admin/notifications/logs/columns";
|
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
|
||||||
import {useRouter} from "next/navigation";
|
import { getChannelIcon } from "@/components/wrappers/dashboard/admin/channels/helpers/common";
|
||||||
import {
|
import {
|
||||||
createRestorationBackupAction, deleteBackupAction, deleteBackupStorageAction,
|
getStatusColor,
|
||||||
downloadBackupAction
|
getStatusIcon,
|
||||||
|
} from "@/components/wrappers/dashboard/admin/notifications/logs/columns";
|
||||||
|
import {
|
||||||
|
createRestorationBackupAction,
|
||||||
|
deleteBackupAction,
|
||||||
|
deleteBackupStorageAction,
|
||||||
|
downloadBackupAction,
|
||||||
} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.action";
|
} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.action";
|
||||||
import {toast} from "sonner";
|
import {
|
||||||
import {SafeActionResult} from "next-safe-action";
|
BackupActionsSchema,
|
||||||
import {ServerActionResult} from "@/types/action-type";
|
BackupActionsType,
|
||||||
import {ZodString} from "zod";
|
} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.schema";
|
||||||
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert";
|
import {
|
||||||
import {AlertCircleIcon} from "lucide-react";
|
DatabaseActionKind,
|
||||||
|
useBackupModal,
|
||||||
|
} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
|
||||||
|
import { Backup, BackupWith, Restoration } from "@/db/schema/07_database";
|
||||||
|
import { BackupStorageWith } from "@/db/schema/14_storage-backup";
|
||||||
|
import { ServerActionResult } from "@/types/action-type";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { AlertCircleIcon } from "lucide-react";
|
||||||
|
import { SafeActionResult } from "next-safe-action";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { ZodString } from "zod";
|
||||||
|
|
||||||
type BackupActionsFormProps = {
|
type BackupActionsFormProps = {
|
||||||
backup: BackupWith;
|
backup: BackupWith;
|
||||||
action: DatabaseActionKind;
|
action: DatabaseActionKind;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
|
export const BackupActionsForm = ({
|
||||||
|
backup,
|
||||||
|
action,
|
||||||
|
}: BackupActionsFormProps) => {
|
||||||
|
const filteredBackupStorages =
|
||||||
|
backup.storages?.filter((storage) => storage.deletedAt === null) ?? [];
|
||||||
|
const { closeModal } = useBackupModal();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const filteredBackupStorages = backup.storages?.filter((storage) => storage.deletedAt === null) ?? []
|
const form = useZodForm({
|
||||||
const {closeModal} = useBackupModal();
|
schema: BackupActionsSchema,
|
||||||
const queryClient = useQueryClient();
|
});
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const form = useZodForm({
|
const mutation = useMutation({
|
||||||
schema: BackupActionsSchema,
|
mutationFn: async (values: BackupActionsType) => {
|
||||||
});
|
let result:
|
||||||
|
| SafeActionResult<
|
||||||
|
string,
|
||||||
|
ZodString,
|
||||||
|
readonly [],
|
||||||
|
{
|
||||||
|
_errors?: string[] | undefined;
|
||||||
|
},
|
||||||
|
readonly [],
|
||||||
|
ServerActionResult<string | Restoration | Backup>,
|
||||||
|
object
|
||||||
|
>
|
||||||
|
| undefined;
|
||||||
|
|
||||||
const mutation = useMutation({
|
if (action === "download") {
|
||||||
mutationFn: async (values: BackupActionsType) => {
|
result = await downloadBackupAction({
|
||||||
|
backupStorageId: values.backupStorageId,
|
||||||
|
});
|
||||||
|
} else if (action === "restore") {
|
||||||
|
result = await createRestorationBackupAction({
|
||||||
|
databaseId: backup.databaseId,
|
||||||
|
backupStorageId: values.backupStorageId,
|
||||||
|
backupId: backup.id,
|
||||||
|
});
|
||||||
|
} else if (action === "delete") {
|
||||||
|
result = await deleteBackupStorageAction({
|
||||||
|
databaseId: backup.databaseId,
|
||||||
|
backupStorageId: values.backupStorageId,
|
||||||
|
backupId: backup.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let result: SafeActionResult<string, ZodString, readonly [], {
|
const inner = result?.data;
|
||||||
_errors?: string[] | undefined;
|
|
||||||
}, readonly [], ServerActionResult<string | Restoration | Backup>, object> | undefined
|
|
||||||
|
|
||||||
if (action === "download") {
|
if (inner?.success) {
|
||||||
result = await downloadBackupAction({backupStorageId: values.backupStorageId})
|
toast.success(inner.actionSuccess?.message);
|
||||||
} else if (action === "restore") {
|
queryClient.invalidateQueries({
|
||||||
result = await createRestorationBackupAction({
|
queryKey: ["database-data", backup.databaseId],
|
||||||
databaseId: backup.databaseId,
|
});
|
||||||
backupStorageId: values.backupStorageId,
|
router.refresh();
|
||||||
backupId: backup.id
|
if (action === "download") {
|
||||||
})
|
const url = inner.value;
|
||||||
} else if (action === "delete") {
|
if (typeof url === "string") {
|
||||||
result = await deleteBackupStorageAction({
|
window.open(url, "_self");
|
||||||
databaseId: backup.databaseId,
|
}
|
||||||
backupStorageId: values.backupStorageId,
|
closeModal();
|
||||||
backupId: backup.id,
|
} else if (action === "restore") {
|
||||||
})
|
closeModal();
|
||||||
}
|
} else if (action === "delete") {
|
||||||
|
closeModal();
|
||||||
|
} else {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (action === "delete") {
|
||||||
|
toast.success("Backup deleted successfully.");
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["database-data", backup.databaseId],
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
closeModal();
|
||||||
|
} else {
|
||||||
|
toast.error(inner?.actionError?.message ?? "An error occurred.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const inner = result?.data;
|
const mutationDeleteEntireBackup = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const result = await deleteBackupAction({
|
||||||
|
databaseId: backup.databaseId,
|
||||||
|
backupId: backup.id,
|
||||||
|
});
|
||||||
|
|
||||||
if (inner?.success) {
|
const inner = result?.data;
|
||||||
toast.success(inner.actionSuccess?.message);
|
|
||||||
queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]});
|
|
||||||
router.refresh();
|
|
||||||
if (action === "download") {
|
|
||||||
const url = inner.value
|
|
||||||
if (typeof url === "string") {
|
|
||||||
window.open(url, "_self");
|
|
||||||
}
|
|
||||||
closeModal()
|
|
||||||
} else if (action === "restore") {
|
|
||||||
closeModal()
|
|
||||||
} else if (action === "delete") {
|
|
||||||
closeModal()
|
|
||||||
} else {
|
|
||||||
closeModal()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (action === "delete") {
|
|
||||||
toast.success("Backup deleted successfully.")
|
|
||||||
queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]});
|
|
||||||
router.refresh();
|
|
||||||
closeModal()
|
|
||||||
} else {
|
|
||||||
toast.error(inner?.actionError?.message ?? "An error occurred.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const mutationDeleteEntireBackup = useMutation({
|
if (inner?.success) {
|
||||||
mutationFn: async () => {
|
toast.success(inner.actionSuccess?.message);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["database-data", backup.databaseId],
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
closeModal();
|
||||||
|
} else {
|
||||||
|
toast.error(inner?.actionError?.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const result = await deleteBackupAction({
|
return (
|
||||||
databaseId: backup.databaseId,
|
<TooltipProvider>
|
||||||
backupId: backup.id,
|
<Form
|
||||||
})
|
form={form}
|
||||||
|
className="flex flex-col gap-4 mb-1 min-w-0"
|
||||||
|
onSubmit={async (values) => {
|
||||||
|
await mutation.mutateAsync(values);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredBackupStorages.length > 0 ? (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="backupStorageId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Choose a storage backup</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-2.5 overflow-y-auto overflow-x-hidden w-full pr-1"
|
||||||
|
style={{ maxHeight: "250px" }}
|
||||||
|
>
|
||||||
|
{filteredBackupStorages.map(
|
||||||
|
(storage: BackupStorageWith) => (
|
||||||
|
<button
|
||||||
|
key={storage.id}
|
||||||
|
disabled={
|
||||||
|
action !== "delete" &&
|
||||||
|
storage.status.toLowerCase() !== "success"
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
onClick={() => field.onChange(storage.id)}
|
||||||
|
className={`w-full flex items-center gap-3 p-4 rounded-lg border text-left transition-colors
|
||||||
|
${
|
||||||
|
field.value === storage.id
|
||||||
|
? "border-foreground bg-background"
|
||||||
|
: "border-border bg-background" +
|
||||||
|
(storage.status.toLowerCase() === "success" ||
|
||||||
|
action === "delete"
|
||||||
|
? " hover:border-muted-foreground"
|
||||||
|
: "")
|
||||||
|
}
|
||||||
|
${
|
||||||
|
storage.status.toLowerCase() !== "success" &&
|
||||||
|
action !== "delete"
|
||||||
|
? "opacity-50 cursor-not-allowed"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`h-4 w-4 shrink-0 rounded-full border ${
|
||||||
|
field.value === storage.id
|
||||||
|
? "border-foreground"
|
||||||
|
: "border-muted-foreground"
|
||||||
|
} flex items-center justify-center`}
|
||||||
|
>
|
||||||
|
{field.value === storage.id && (
|
||||||
|
<div className="h-2 w-2 rounded-full bg-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
const inner = result?.data;
|
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||||
|
<div className="shrink-0">
|
||||||
|
{getChannelIcon(
|
||||||
|
storage.storageChannel?.provider || "",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
if (inner?.success) {
|
<h3 className="font-medium text-foreground truncate flex-1">
|
||||||
toast.success(inner.actionSuccess?.message);
|
{storage.storageChannel?.name}
|
||||||
queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]});
|
</h3>
|
||||||
router.refresh();
|
|
||||||
closeModal()
|
|
||||||
} else {
|
|
||||||
toast.error(inner?.actionError?.message);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
{storage.storageChannel?.provider && (
|
||||||
<TooltipProvider>
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="text-xs font-mono shrink-0"
|
||||||
|
>
|
||||||
|
{storage.storageChannel.provider}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`gap-1.5 shrink-0 ${getStatusColor(storage.status)}`}
|
||||||
|
>
|
||||||
|
{getStatusIcon(storage.status === "success")}
|
||||||
|
<span className="capitalize hidden sm:inline">
|
||||||
|
{storage.status.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
) ?? <p>No storages available</p>}
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Alert>
|
||||||
|
<AlertCircleIcon />
|
||||||
|
<AlertTitle>Backup does not have files</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<p>
|
||||||
|
You can safely delete the entire backup; no files seem to be
|
||||||
|
related. Maybe an error occurred.
|
||||||
|
</p>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<Form
|
<div className="flex flex-row items-center gap-x-4 w-full">
|
||||||
form={form}
|
{action === "delete" && (
|
||||||
className="flex flex-col gap-4 mb-1"
|
<ButtonWithLoading
|
||||||
onSubmit={async (values) => {
|
type="button"
|
||||||
await mutation.mutateAsync(values);
|
variant="destructive"
|
||||||
}}
|
onClick={() => mutationDeleteEntireBackup.mutateAsync()}
|
||||||
|
isPending={mutationDeleteEntireBackup.isPending}
|
||||||
|
disabled={mutationDeleteEntireBackup.isPending}
|
||||||
>
|
>
|
||||||
|
Delete entire backup
|
||||||
|
</ButtonWithLoading>
|
||||||
|
)}
|
||||||
|
|
||||||
{filteredBackupStorages.length > 0 ?
|
{filteredBackupStorages.length > 0 && (
|
||||||
<FormField
|
<ButtonWithLoading
|
||||||
control={form.control}
|
type="submit"
|
||||||
name="backupStorageId"
|
isPending={mutation.isPending}
|
||||||
render={({field}) => (
|
disabled={mutation.isPending}
|
||||||
<FormItem>
|
className="ml-auto"
|
||||||
<FormLabel>Choose a storage backup</FormLabel>
|
>
|
||||||
<FormControl>
|
Confirm
|
||||||
|
</ButtonWithLoading>
|
||||||
<div style={{height: "250px"}}>
|
)}
|
||||||
<Swiper
|
</div>
|
||||||
direction="vertical"
|
</Form>
|
||||||
slidesPerView={3.5}
|
</TooltipProvider>
|
||||||
spaceBetween={10}
|
);
|
||||||
// pagination={{ clickable: true }}
|
};
|
||||||
mousewheel={{releaseOnEdges: true, forceToAxis: true}}
|
|
||||||
modules={[Pagination, Mousewheel]}
|
|
||||||
className="mySwiper"
|
|
||||||
style={{height: "100%"}}
|
|
||||||
>
|
|
||||||
{filteredBackupStorages.map((storage: BackupStorageWith) => (
|
|
||||||
<SwiperSlide key={storage.id}>
|
|
||||||
<button
|
|
||||||
disabled={action !== "delete" && storage.status.toLowerCase() !== "success"}
|
|
||||||
type="button"
|
|
||||||
onClick={() => field.onChange(storage.id)}
|
|
||||||
className={`w-full h-full flex items-start gap-3 p-4 rounded-lg border text-left transition-colors
|
|
||||||
${field.value === storage.id
|
|
||||||
? "border-foreground bg-background"
|
|
||||||
: "border-border bg-background" + ((storage.status.toLowerCase() === "success" || action === "delete") ? " hover:border-muted-foreground" : "")}
|
|
||||||
${storage.status.toLowerCase() !== "success" && action !== "delete" ? "opacity-50 cursor-not-allowed" : ""}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`mt-0.5 h-4 w-4 shrink-0 rounded-full border ${
|
|
||||||
field.value === storage.id ? "border-foreground" : "border-muted-foreground"
|
|
||||||
} flex items-center justify-center`}
|
|
||||||
>
|
|
||||||
{field.value === storage.id &&
|
|
||||||
<div className="h-2 w-2 rounded-full bg-foreground"/>}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex-1 min-w-0 flex flex-col gap-1">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<div className="shrink-0">
|
|
||||||
{getChannelIcon(storage.storageChannel?.provider || "")}
|
|
||||||
</div>
|
|
||||||
<h3 className="font-medium text-foreground truncate">
|
|
||||||
{storage.storageChannel?.name}
|
|
||||||
</h3>
|
|
||||||
<Badge variant="secondary"
|
|
||||||
className="text-xs font-mono shrink-0">
|
|
||||||
{storage.storageChannel?.provider}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<Badge variant="outline"
|
|
||||||
className={`gap-1.5 shrink-0 ${getStatusColor(storage.status)}`}>
|
|
||||||
{getStatusIcon(storage.status === "success")}
|
|
||||||
<span
|
|
||||||
className="capitalize">{storage.status.toUpperCase()}</span>
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</SwiperSlide>
|
|
||||||
)) ?? <p>No storages available</p>}
|
|
||||||
</Swiper>
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage/>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
:
|
|
||||||
<Alert>
|
|
||||||
<AlertCircleIcon/>
|
|
||||||
<AlertTitle>Backup does not have files</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
<p>You can safely delete the entire backup; no files seem to be related. Maybe an error
|
|
||||||
occurred.</p>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div className="flex flex-row items-center gap-x-4 w-full">
|
|
||||||
{action === "delete" && (
|
|
||||||
<ButtonWithLoading
|
|
||||||
type="button"
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => mutationDeleteEntireBackup.mutateAsync()}
|
|
||||||
isPending={mutationDeleteEntireBackup.isPending}
|
|
||||||
disabled={mutationDeleteEntireBackup.isPending}
|
|
||||||
>
|
|
||||||
Delete entire backup
|
|
||||||
</ButtonWithLoading>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{filteredBackupStorages.length > 0 && (
|
|
||||||
<ButtonWithLoading
|
|
||||||
type="submit"
|
|
||||||
isPending={mutation.isPending}
|
|
||||||
disabled={mutation.isPending}
|
|
||||||
className="ml-auto"
|
|
||||||
>
|
|
||||||
Confirm
|
|
||||||
</ButtonWithLoading>
|
|
||||||
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</TooltipProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
|
||||||
import { GearIcon } from "@radix-ui/react-icons";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
|
|
||||||
export type EditButtonProps = {};
|
|
||||||
|
|
||||||
export const EditButton = (props: EditButtonProps) => {
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link className={buttonVariants({ variant: "outline" })} href={`${pathname}/edit`}>
|
|
||||||
<GearIcon className="w-7 h-7" />
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -8,7 +8,7 @@ import {v4 as uuidv4} from "uuid";
|
|||||||
import {eq} from "drizzle-orm";
|
import {eq} from "drizzle-orm";
|
||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
import {storeBackupFiles} from "@/features/storages/helpers";
|
import {storeBackupFiles} from "@/features/storages/helpers";
|
||||||
import {getFileExtension} from "@/features/api/upload/helpers/file";
|
import {getFileExtension} from "@/utils/common";
|
||||||
|
|
||||||
|
|
||||||
export const uploadBackupAction = userAction
|
export const uploadBackupAction = userAction
|
||||||
@@ -46,18 +46,6 @@ export const uploadBackupAction = userAction
|
|||||||
const fileName = `${uuid}${fileExtension}`;
|
const fileName = `${uuid}${fileExtension}`;
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
|
||||||
// const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
|
|
||||||
//
|
|
||||||
// if (!settings) {
|
|
||||||
// return {
|
|
||||||
// success: false,
|
|
||||||
// actionError: {
|
|
||||||
// message: "Settings not set",
|
|
||||||
// status: 500,
|
|
||||||
// cause: "Unknown error",
|
|
||||||
// },
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
|
|
||||||
const [backup] = await db
|
const [backup] = await db
|
||||||
.insert(drizzleDb.schemas.backup)
|
.insert(drizzleDb.schemas.backup)
|
||||||
@@ -68,30 +56,8 @@ export const uploadBackupAction = userAction
|
|||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|
||||||
await storeBackupFiles(backup, database, buffer, fileName)
|
await storeBackupFiles(backup, database, buffer, fileName)
|
||||||
|
|
||||||
|
|
||||||
// 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 {
|
|
||||||
// success: false,
|
|
||||||
// actionError: {
|
|
||||||
// message: "An error has occurred while uploading file",
|
|
||||||
// status: 500,
|
|
||||||
// cause: "Unknown error",
|
|
||||||
// },
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
value: backup,
|
value: backup,
|
||||||
|
|||||||
@@ -3,13 +3,18 @@
|
|||||||
import { ServerActionResult } from "@/types/action-type";
|
import { ServerActionResult } from "@/types/action-type";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { auth } from "@/lib/auth/auth";
|
import { auth, getPasskeys, revokePasskey } from "@/lib/auth/auth";
|
||||||
import {userAction} from "@/lib/safe-actions/actions";
|
import { userAction } from "@/lib/safe-actions/actions";
|
||||||
|
|
||||||
const RevokeSessionSchema = z.object({
|
const RevokeSessionSchema = z.object({
|
||||||
token: z.string(),
|
token: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const RevokePasskeySchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
export const revokeSessionAction = userAction.schema(RevokeSessionSchema).action(async ({ parsedInput }): Promise<ServerActionResult<{}>> => {
|
export const revokeSessionAction = userAction.schema(RevokeSessionSchema).action(async ({ parsedInput }): Promise<ServerActionResult<{}>> => {
|
||||||
try {
|
try {
|
||||||
const session = await auth.api.getSession({
|
const session = await auth.api.getSession({
|
||||||
@@ -99,3 +104,46 @@ export const revokeAllSessionsAction = userAction.action(async (): Promise<Serve
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
export const getPasskeysAction = userAction.action(async (): Promise<ServerActionResult<any[]>> => {
|
||||||
|
try {
|
||||||
|
const passkeys = await getPasskeys();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
value: passkeys || [],
|
||||||
|
actionSuccess: {
|
||||||
|
message: "passkeys_fetched",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
actionError: {
|
||||||
|
message: "error_fetching_passkeys",
|
||||||
|
cause: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const revokePasskeyAction = userAction.schema(RevokePasskeySchema).action(async ({ parsedInput }): Promise<ServerActionResult<{}>> => {
|
||||||
|
try {
|
||||||
|
await revokePasskey(parsedInput.id);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
value: {},
|
||||||
|
actionSuccess: {
|
||||||
|
message: "passkey_revoked",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
actionError: {
|
||||||
|
message: "error_revoking_passkey",
|
||||||
|
cause: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,184 +1,234 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, {useState} from "react";
|
import { useState } from "react";
|
||||||
import {Button} from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {Badge} from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {Loader2, AlertTriangle} from "lucide-react";
|
import { Loader2, AlertTriangle } from "lucide-react";
|
||||||
import {Account} from "@/db/schema/02_user";
|
import { authClient } from "@/lib/auth/auth-client";
|
||||||
import {authClient} from "@/lib/auth/auth-client";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import {useMutation} from "@tanstack/react-query";
|
import { toast } from "sonner";
|
||||||
import {toast} from "sonner";
|
import { useRouter } from "next/navigation";
|
||||||
import {useRouter} from "next/navigation";
|
import {
|
||||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
|
Tooltip,
|
||||||
import {Alert, AlertDescription} from "@/components/ui/alert";
|
TooltipContent,
|
||||||
import {SetPasswordProfileProviderModal} from "./modal/set-password-modal";
|
TooltipTrigger,
|
||||||
import {AuthProviderConfig} from "../../../../../portabase.config";
|
} from "@/components/ui/tooltip";
|
||||||
import {Icon} from "@iconify/react";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { SetPasswordProfileProviderModal } from "./modal/set-password-modal";
|
||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import type { AuthProviderConfig } from "@/lib/auth/config";
|
||||||
|
import type { Account } from "@/db/schema/02_user";
|
||||||
|
|
||||||
interface ProfileProviderProps {
|
interface ProfileProviderProps {
|
||||||
accounts: Account[];
|
accounts: Account[];
|
||||||
providers: AuthProviderConfig[]
|
providers: AuthProviderConfig[];
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProfileProviders({accounts, providers}: ProfileProviderProps) {
|
export function ProfileProviders({
|
||||||
const router = useRouter();
|
accounts,
|
||||||
const totalConnected = accounts.length;
|
providers,
|
||||||
const [loadingProvider, setLoadingProvider] = useState<string | null>(null);
|
}: ProfileProviderProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const totalConnected = accounts.length;
|
||||||
|
const [loadingProvider, setLoadingProvider] = useState<string | null>(null);
|
||||||
|
|
||||||
const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false);
|
const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false);
|
||||||
|
|
||||||
const {mutate: unlinkAccount} = useMutation({
|
const { mutate: unlinkAccount } = useMutation({
|
||||||
mutationFn: async (providerId: string) => {
|
mutationFn: async (providerId: string) => {
|
||||||
setLoadingProvider(providerId);
|
setLoadingProvider(providerId);
|
||||||
const {error} = await authClient.unlinkAccount({ providerId });
|
const { error } = await authClient.unlinkAccount({ providerId });
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Provider successfully unlinked!");
|
toast.success("Provider successfully unlinked!");
|
||||||
setLoadingProvider(null);
|
setLoadingProvider(null);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast.error("An error occurred while unlinking provider.");
|
toast.error("An error occurred while unlinking provider.");
|
||||||
setLoadingProvider(null);
|
setLoadingProvider(null);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const {mutate: linkAccount} = useMutation({
|
const { mutate: linkAccount } = useMutation({
|
||||||
mutationFn: async (providerId: string) => {
|
mutationFn: async (provider: AuthProviderConfig) => {
|
||||||
setLoadingProvider(providerId);
|
setLoadingProvider(provider.id);
|
||||||
const {error} = await authClient.signIn.social({
|
if (provider.type === "social") {
|
||||||
provider: providerId as "google" | "github" | "credential",
|
await authClient.linkSocial({
|
||||||
callbackURL: "/dashboard",
|
provider: provider.id as string,
|
||||||
});
|
callbackURL: "/dashboard",
|
||||||
if (error) throw error;
|
});
|
||||||
},
|
}
|
||||||
onSuccess: () => {
|
},
|
||||||
toast.success("Provider successfully Linked!");
|
onSuccess: () => {
|
||||||
setLoadingProvider(null);
|
setLoadingProvider(null);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast.error("An error occurred while linking provider.");
|
toast.error("An error occurred while linking provider.");
|
||||||
setLoadingProvider(null);
|
setLoadingProvider(null);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const enterpriseProviders = providers.filter(
|
||||||
|
(p) => p.type === "sso" && p.id !== "passkey" && p.type !== "credential",
|
||||||
|
);
|
||||||
|
const otherProviders = providers.filter(
|
||||||
|
(p) => p.type !== "sso" && p.id !== "passkey" && p.type !== "credential",
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderProvider = (provider: AuthProviderConfig) => {
|
||||||
|
const linkedAccount = accounts.find(
|
||||||
|
(acc) => acc.providerId === provider.id,
|
||||||
|
);
|
||||||
|
const isConnected = !!linkedAccount;
|
||||||
|
const canUnlink =
|
||||||
|
totalConnected > 1 || (totalConnected === 1 && !provider.isManual);
|
||||||
|
const isLoading = loadingProvider === provider.id;
|
||||||
|
|
||||||
|
const isUnlinkDisabled =
|
||||||
|
!canUnlink ||
|
||||||
|
provider.allowUnlinking === false ||
|
||||||
|
provider.type === "sso";
|
||||||
|
|
||||||
|
const unlinkButton = (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => unlinkAccount(provider.id)}
|
||||||
|
disabled={isUnlinkDisabled || isLoading || provider.isManual}
|
||||||
|
className={isUnlinkDisabled ? "opacity-50 cursor-not-allowed" : ""}
|
||||||
|
>
|
||||||
|
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : "Unlink"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
let actionElement;
|
||||||
|
|
||||||
|
if (provider.type === "sso") {
|
||||||
|
actionElement = null;
|
||||||
|
} else if (!isConnected) {
|
||||||
|
actionElement =
|
||||||
|
provider.id === "credential" ? (
|
||||||
|
<SetPasswordProfileProviderModal
|
||||||
|
open={isPasswordDialogOpen}
|
||||||
|
onOpenChange={setIsPasswordDialogOpen}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => linkAccount(provider)}
|
||||||
|
disabled={
|
||||||
|
isLoading || provider.isManual || provider.allowLinking === false
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : "Link"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
} else if (isUnlinkDisabled) {
|
||||||
|
actionElement = (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div tabIndex={0} className="inline-block">
|
||||||
|
{unlinkButton}
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
{provider.allowUnlinking === false
|
||||||
|
? "Unlinking is disabled for this provider."
|
||||||
|
: "You cannot unlink your last authentication provider."}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
actionElement = unlinkButton;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 animate-in fade-in-50 duration-300">
|
<div
|
||||||
<div className="mb-6 space-y-1">
|
key={provider.id}
|
||||||
<h2 className="text-2xl font-semibold tracking-tight">Connected Accounts</h2>
|
className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted/30 transition-colors"
|
||||||
<p className="text-sm text-muted-foreground">Manage the providers used to sign in to your account.</p>
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center">
|
||||||
|
{provider.icon.startsWith("/") ||
|
||||||
|
provider.icon.startsWith("http") ? (
|
||||||
|
<Image
|
||||||
|
src={provider.icon}
|
||||||
|
alt={provider.id}
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
className="w-5 h-5"
|
||||||
|
unoptimized={provider.icon.startsWith("http")}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Icon icon={provider.icon} className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="font-medium flex items-center gap-2">
|
||||||
|
{provider.title || provider.name}
|
||||||
|
{isConnected && (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="text-[10px] h-5 px-1.5 text-green-600 bg-green-500/10 border-0"
|
||||||
|
>
|
||||||
|
Active
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
<div className="grid gap-4">
|
{provider.description}
|
||||||
{providers.map((provider) => {
|
|
||||||
const linkedAccount = accounts.find((acc) => acc.providerId === provider.id);
|
|
||||||
const isConnected = !!linkedAccount;
|
|
||||||
// const canUnlink = totalConnected > 1 || (totalConnected === 1 && !provider.isManual);
|
|
||||||
const canUnlink = totalConnected > 1 ;
|
|
||||||
// const isLoading = isUnlinking || isLinking;
|
|
||||||
const isLoading = loadingProvider === provider.id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={provider.id}
|
|
||||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted/30 transition-colors">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center">
|
|
||||||
<Icon icon={provider.icon} className="w-5 h-5"/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<div className="font-medium flex items-center gap-2">
|
|
||||||
{PROVIDERS_TEXT[provider.id].title}
|
|
||||||
{isConnected && (
|
|
||||||
<Badge variant="secondary"
|
|
||||||
className="text-[10px] h-5 px-1.5 text-green-600 bg-green-500/10 border-0">
|
|
||||||
Active
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{isConnected ? "Connected" : "Not Connected"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{isConnected ? (
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span tabIndex={0}>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => unlinkAccount(provider.id)}
|
|
||||||
disabled={!canUnlink || isLoading || provider.isManual}
|
|
||||||
className={!canUnlink ? "opacity-50 cursor-not-allowed" : ""}
|
|
||||||
>
|
|
||||||
{isLoading ? <Loader2
|
|
||||||
className="w-4 h-4 animate-spin"/> : "Unlink"}
|
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
{!canUnlink && (
|
|
||||||
<TooltipContent>
|
|
||||||
<p>
|
|
||||||
You cannot unlink your last authentication provider or if you
|
|
||||||
don't have a password set.
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
)}
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{provider.id === "credential" ? (
|
|
||||||
<SetPasswordProfileProviderModal open={isPasswordDialogOpen}
|
|
||||||
onOpenChange={setIsPasswordDialogOpen}/>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => linkAccount(provider.id)}
|
|
||||||
disabled={isLoading || provider.isManual}
|
|
||||||
>
|
|
||||||
{isLoading ? <Loader2 className="w-4 h-4 animate-spin"/> : "Link"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<Alert variant={"default"}>
|
|
||||||
<AlertTriangle className="w-5 h-5 shrink-0 mt-0.5"/>
|
|
||||||
<AlertDescription>
|
|
||||||
Linked providers allow you to log in to your account using any of these methods. If you use the same
|
|
||||||
email address with another provider, it will be automatically linked when you log in.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">{actionElement}</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 animate-in fade-in-50 duration-300 pb-10">
|
||||||
|
<div className="mb-6 space-y-1">
|
||||||
|
<h2 className="text-2xl font-semibold tracking-tight">
|
||||||
|
Authentication
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Manage how you access your account.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{enterpriseProviders.length > 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
Enterprise Connection
|
||||||
|
</h3>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{enterpriseProviders.map(renderProvider)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
Standard Connections
|
||||||
|
</h3>
|
||||||
|
<div className="grid gap-4">{otherProviders.map(renderProvider)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert variant={"default"}>
|
||||||
|
<AlertTriangle className="w-5 h-5 shrink-0 mt-0.5" />
|
||||||
|
<AlertDescription>
|
||||||
|
Linked providers allow you to log in to your account using any of
|
||||||
|
these methods.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const PROVIDERS_TEXT = {
|
|
||||||
credential: {
|
|
||||||
title: "Password",
|
|
||||||
description: "Use your email address and password to sign in.",
|
|
||||||
},
|
|
||||||
google: {
|
|
||||||
title: "Google",
|
|
||||||
description: "Sign in with your Google account.",
|
|
||||||
},
|
|
||||||
github: {
|
|
||||||
title: "GitHub",
|
|
||||||
description: "Sign in with your GitHub account.",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,220 +1,493 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {useState} from "react";
|
import { useState } from "react";
|
||||||
import {Button} from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {Separator} from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import {Badge} from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {Globe, LogOut, Loader2} from "lucide-react";
|
import {
|
||||||
import {Account, Session, User} from "@/db/schema/02_user";
|
Globe,
|
||||||
import {useMutation} from "@tanstack/react-query";
|
LogOut,
|
||||||
import {toast} from "sonner";
|
Loader2,
|
||||||
import {revokeAllSessionsAction, revokeSessionAction} from "./actions/security.action";
|
Fingerprint,
|
||||||
import {useRouter} from "next/navigation";
|
Trash2,
|
||||||
import {ResetPasswordProfileProviderModal} from "./modal/reset-password-modal";
|
Plus,
|
||||||
import {SetPasswordProfileProviderModal} from "./modal/set-password-modal";
|
} from "lucide-react";
|
||||||
import {Setup2FAProfileProviderModal} from "./modal/setup-2fa-modal";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import {Disable2FAProfileProviderModal} from "./modal/disable-2fa-modal";
|
import { toast } from "sonner";
|
||||||
import {ViewBackupCodesModal} from "./modal/view-backup-codes-modal";
|
import {
|
||||||
import {getDeviceDetails} from "@/utils/detection";
|
revokeAllSessionsAction,
|
||||||
import {timeAgo} from "@/utils/date-formatting";
|
revokeSessionAction,
|
||||||
|
getPasskeysAction,
|
||||||
|
revokePasskeyAction,
|
||||||
|
} from "./actions/security.action";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { ResetPasswordProfileProviderModal } from "./modal/reset-password-modal";
|
||||||
|
import { SetPasswordProfileProviderModal } from "./modal/set-password-modal";
|
||||||
|
import { Setup2FAProfileProviderModal } from "./modal/setup-2fa-modal";
|
||||||
|
import { Disable2FAProfileProviderModal } from "./modal/disable-2fa-modal";
|
||||||
|
import { ViewBackupCodesModal } from "./modal/view-backup-codes-modal";
|
||||||
|
import { getDeviceDetails } from "@/utils/detection";
|
||||||
|
import { timeAgo } from "@/utils/date-formatting";
|
||||||
|
import { authClient } from "@/lib/auth/auth-client";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Account, Session, User } from "@/db/schema/02_user";
|
||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import type { AuthProviderConfig } from "@/lib/auth/config";
|
||||||
|
import { is } from "date-fns/locale";
|
||||||
|
|
||||||
interface ProfileSecurityProps {
|
interface ProfileSecurityProps {
|
||||||
user: User;
|
user: User;
|
||||||
sessions: Session[];
|
sessions: Session[];
|
||||||
credentialAccount: Account;
|
credentialAccount: Account;
|
||||||
currentSession: Session;
|
currentSession: Session;
|
||||||
|
isPasswordEnabled?: boolean;
|
||||||
|
isPasskeyEnabled?: boolean;
|
||||||
|
providers: AuthProviderConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProfileSecurity({user, sessions, credentialAccount, currentSession}: ProfileSecurityProps) {
|
export function ProfileSecurity({
|
||||||
const router = useRouter();
|
user,
|
||||||
|
sessions,
|
||||||
|
credentialAccount,
|
||||||
|
currentSession,
|
||||||
|
isPasswordEnabled = false,
|
||||||
|
isPasskeyEnabled = false,
|
||||||
|
providers,
|
||||||
|
}: ProfileSecurityProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const [isBackupCodesDialogOpen, setIsBackupCodesDialogOpen] = useState(false);
|
const [isBackupCodesDialogOpen, setIsBackupCodesDialogOpen] = useState(false);
|
||||||
const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false);
|
const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false);
|
||||||
const [isSetup2FADialogOpen, setIsSetup2FADialogOpen] = useState(false);
|
const [isSetup2FADialogOpen, setIsSetup2FADialogOpen] = useState(false);
|
||||||
const [isDisable2FADialogOpen, setIsDisable2FADialogOpen] = useState(false);
|
const [isDisable2FADialogOpen, setIsDisable2FADialogOpen] = useState(false);
|
||||||
|
const [isAddPasskeyOpen, setIsAddPasskeyOpen] = useState(false);
|
||||||
|
const [passkeyName, setPasskeyName] = useState("");
|
||||||
|
|
||||||
const {mutate: revokeSession, isPending: isRevoking} = useMutation({
|
const { mutate: revokeSession, isPending: isRevoking } = useMutation({
|
||||||
mutationFn: async (token: string) => {
|
mutationFn: async (token: string) => {
|
||||||
const result = await revokeSessionAction({token});
|
const result = await revokeSessionAction({ token });
|
||||||
const inner = result?.data;
|
const inner = result?.data;
|
||||||
if (inner?.success) {
|
if (inner?.success) {
|
||||||
toast.success("Session successfully revoked");
|
toast.success("Session successfully revoked");
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
toast.error("An error occurred while revoking session");
|
toast.error("An error occurred while revoking session");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const {mutate: revokeOthers, isPending: isRevokingOthers} = useMutation({
|
const { mutate: revokeOthers, isPending: isRevokingOthers } = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const result = await revokeAllSessionsAction();
|
const result = await revokeAllSessionsAction();
|
||||||
const inner = result?.data;
|
const inner = result?.data;
|
||||||
if (inner?.success) {
|
if (inner?.success) {
|
||||||
toast.success("Revoking all sessions successfully done.");
|
toast.success("Revoking all sessions successfully done.");
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
toast.error("An error occurred while revoking all sessions");
|
toast.error("An error occurred while revoking all sessions");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
const {
|
||||||
<div className="space-y-8 animate-in fade-in-50 duration-300">
|
data: passkeys,
|
||||||
<div className="mb-6 space-y-1">
|
isLoading: isLoadingPasskeys,
|
||||||
<h2 className="text-2xl font-semibold tracking-tight">Security Settings</h2>
|
refetch: refetchPasskeys,
|
||||||
<p className="text-sm text-muted-foreground">Manage your password, two-factor authentication and
|
} = useQuery({
|
||||||
sessions.</p>
|
queryKey: ["passkeys"],
|
||||||
</div>
|
queryFn: async () => {
|
||||||
|
const result = await getPasskeysAction();
|
||||||
|
if (result?.data?.success) {
|
||||||
|
return result.data.value;
|
||||||
|
}
|
||||||
|
throw new Error("Failed to fetch passkeys");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
<div className="space-y-6">
|
const { mutate: revokePasskey, isPending: isRevokingPasskey } = useMutation({
|
||||||
<h3 className="text-lg font-medium">Authentication</h3>
|
mutationFn: async (id: string) => {
|
||||||
<div className="border rounded-lg p-4 space-y-4">
|
const result = await revokePasskeyAction({ id });
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
if (!result?.data?.success) {
|
||||||
<div className="space-y-1">
|
throw new Error("Failed to revoke passkey");
|
||||||
<div className="font-medium">Password</div>
|
}
|
||||||
<div className="text-sm text-muted-foreground">
|
},
|
||||||
{user.lastChangedPasswordAt
|
onSuccess: () => {
|
||||||
? `Last changed ${timeAgo(new Date(user.lastChangedPasswordAt))}`
|
toast.success("Passkey revoked successfully");
|
||||||
: "Never changed"}
|
refetchPasskeys();
|
||||||
</div>
|
},
|
||||||
</div>
|
onError: () => {
|
||||||
{credentialAccount ? (
|
toast.error("Failed to revoke passkey");
|
||||||
<ResetPasswordProfileProviderModal open={isPasswordDialogOpen}
|
},
|
||||||
onOpenChange={setIsPasswordDialogOpen}/>
|
});
|
||||||
) : (
|
|
||||||
<SetPasswordProfileProviderModal open={isPasswordDialogOpen}
|
|
||||||
onOpenChange={setIsPasswordDialogOpen}/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator/>
|
const { mutate: addPasskey, isPending: isAddingPasskey } = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const result = await authClient.passkey.addPasskey({
|
||||||
|
name: passkeyName || "My Passkey",
|
||||||
|
});
|
||||||
|
if (result?.error) {
|
||||||
|
throw result.error;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Passkey added successfully");
|
||||||
|
setIsAddPasskeyOpen(false);
|
||||||
|
setPasskeyName("");
|
||||||
|
refetchPasskeys();
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.message || "Failed to add passkey");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-8 animate-in fade-in-50 duration-300">
|
||||||
<div className="flex items-center gap-2">
|
<div className="mb-6 space-y-1">
|
||||||
<div className="font-medium">Two-Factor Authentication</div>
|
<h2 className="text-2xl font-semibold tracking-tight">
|
||||||
{user.twoFactorEnabled && (
|
Security Settings
|
||||||
<Badge variant="secondary"
|
</h2>
|
||||||
className="text-[10px] h-5 px-1.5 text-green-600 bg-green-500/10 border-0">
|
<p className="text-sm text-muted-foreground">
|
||||||
Active
|
Manage your password, two-factor authentication and sessions.
|
||||||
</Badge>
|
</p>
|
||||||
)}
|
</div>
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">Enhance the security of your account by
|
|
||||||
requiring a second form of verification during login.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{user.twoFactorEnabled ? (
|
{(isPasskeyEnabled || isPasswordEnabled || user.twoFactorEnabled) && (
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="space-y-6">
|
||||||
<ViewBackupCodesModal open={isBackupCodesDialogOpen}
|
<h3 className="text-lg font-medium">Authentication</h3>
|
||||||
onOpenChange={setIsBackupCodesDialogOpen}/>
|
<div className="border rounded-lg p-4 space-y-4">
|
||||||
<Disable2FAProfileProviderModal open={isDisable2FADialogOpen}
|
{isPasswordEnabled && (
|
||||||
onOpenChange={setIsDisable2FADialogOpen}/>
|
<>
|
||||||
</div>
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
) : (
|
<div className="space-y-1">
|
||||||
<Setup2FAProfileProviderModal
|
<div className="font-medium">Password</div>
|
||||||
disabled={!credentialAccount}
|
<div className="text-sm text-muted-foreground">
|
||||||
open={isSetup2FADialogOpen}
|
{user.lastChangedPasswordAt
|
||||||
onOpenChange={setIsSetup2FADialogOpen}
|
? `Last changed ${timeAgo(new Date(user.lastChangedPasswordAt))}`
|
||||||
/>
|
: "Never changed"}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{credentialAccount ? (
|
||||||
|
<ResetPasswordProfileProviderModal
|
||||||
|
open={isPasswordDialogOpen}
|
||||||
|
onOpenChange={setIsPasswordDialogOpen}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SetPasswordProfileProviderModal
|
||||||
|
open={isPasswordDialogOpen}
|
||||||
|
onOpenChange={setIsPasswordDialogOpen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
<Separator />
|
||||||
<div className="flex items-center justify-between">
|
</>
|
||||||
<h3 className="text-lg font-medium">Active Sessions</h3>
|
)}
|
||||||
{sessions && sessions.length > 1 && (
|
|
||||||
<Button
|
{isPasswordEnabled && (
|
||||||
variant="ghost"
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
size="sm"
|
<div className="space-y-1">
|
||||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => revokeOthers()}
|
<div className="font-medium">Two-Factor Authentication</div>
|
||||||
disabled={isRevokingOthers || (sessions?.length || 0) <= 1}
|
{user.twoFactorEnabled && (
|
||||||
>
|
<Badge
|
||||||
{isRevokingOthers && <Loader2 className="mr-2 h-4 w-4 animate-spin"/>}
|
variant="secondary"
|
||||||
Revoke All
|
className="text-[10px] h-5 px-1.5 text-green-600 bg-green-500/10 border-0"
|
||||||
</Button>
|
>
|
||||||
|
Active
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Enhance the security of your account by requiring a second
|
||||||
|
form of verification during login.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="border rounded-lg divide-y">
|
|
||||||
{sessions && sessions.length > 0 ? (
|
{user.twoFactorEnabled ? (
|
||||||
sessions?.map((session) => (
|
<div className="flex flex-col items-center gap-2">
|
||||||
<SessionRow
|
<ViewBackupCodesModal
|
||||||
key={session.id}
|
open={isBackupCodesDialogOpen}
|
||||||
session={session}
|
onOpenChange={setIsBackupCodesDialogOpen}
|
||||||
onRevoke={(token) => revokeSession(token)}
|
/>
|
||||||
isRevoking={isRevoking}
|
<Disable2FAProfileProviderModal
|
||||||
currentSession={currentSession}
|
open={isDisable2FADialogOpen}
|
||||||
/>
|
onOpenChange={setIsDisable2FADialogOpen}
|
||||||
))
|
/>
|
||||||
) : (
|
</div>
|
||||||
<div className="p-4 text-center text-muted-foreground">No active sessions found.</div>
|
) : (
|
||||||
)}
|
<Setup2FAProfileProviderModal
|
||||||
</div>
|
disabled={!credentialAccount}
|
||||||
</div>
|
open={isSetup2FADialogOpen}
|
||||||
|
onOpenChange={setIsSetup2FADialogOpen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
|
|
||||||
|
{isPasskeyEnabled && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-lg font-medium">Passkeys</h3>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Login securely with your fingerprint, face recognition, or
|
||||||
|
hardware key.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={isAddPasskeyOpen} onOpenChange={setIsAddPasskeyOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Passkey
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add New Passkey</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a name for your passkey to identify it later.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="name">Passkey Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
placeholder="e.g. MacBook Pro, iPhone, YubiKey"
|
||||||
|
value={passkeyName}
|
||||||
|
onChange={(e) => setPasskeyName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsAddPasskeyOpen(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => addPasskey()}
|
||||||
|
disabled={isAddingPasskey}
|
||||||
|
>
|
||||||
|
{isAddingPasskey && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Create Passkey
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border rounded-lg divide-y">
|
||||||
|
{isLoadingPasskeys ? (
|
||||||
|
<div className="flex items-center justify-center p-4">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : passkeys && passkeys.length > 0 ? (
|
||||||
|
passkeys.map((pk: any) => (
|
||||||
|
<PasskeyRow
|
||||||
|
key={pk.id}
|
||||||
|
passkey={pk}
|
||||||
|
onRevoke={(id) => revokePasskey(id)}
|
||||||
|
isRevoking={isRevokingPasskey}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="p-4 text-center text-muted-foreground">
|
||||||
|
No passkeys found.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-6 pb-10">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-medium">Active Sessions</h3>
|
||||||
|
{sessions && sessions.length > 1 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
onClick={() => revokeOthers()}
|
||||||
|
disabled={isRevokingOthers || (sessions?.length || 0) <= 1}
|
||||||
|
>
|
||||||
|
{isRevokingOthers && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Revoke All
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="border rounded-lg divide-y">
|
||||||
|
{sessions && sessions.length > 0 ? (
|
||||||
|
sessions?.map((session) => (
|
||||||
|
<SessionRow
|
||||||
|
key={session.id}
|
||||||
|
session={session}
|
||||||
|
onRevoke={(token) => revokeSession(token)}
|
||||||
|
isRevoking={isRevoking}
|
||||||
|
currentSession={currentSession}
|
||||||
|
providers={providers}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="p-4 text-center text-muted-foreground">
|
||||||
|
No active sessions found.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SessionRow({
|
function SessionRow({
|
||||||
session,
|
session,
|
||||||
onRevoke,
|
onRevoke,
|
||||||
isRevoking,
|
isRevoking,
|
||||||
currentSession,
|
currentSession,
|
||||||
}: {
|
providers,
|
||||||
session: Session;
|
}: {
|
||||||
onRevoke: (token: string) => void;
|
session: Session;
|
||||||
isRevoking: boolean;
|
onRevoke: (token: string) => void;
|
||||||
currentSession: Session;
|
isRevoking: boolean;
|
||||||
|
currentSession: Session;
|
||||||
|
providers: AuthProviderConfig[];
|
||||||
}) {
|
}) {
|
||||||
const deviceInfo = getDeviceDetails(session.userAgent);
|
const deviceInfo = getDeviceDetails(session.userAgent);
|
||||||
|
const provider = providers.find((p) => p.id === (session as any).providerId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between p-4">
|
<div className="flex items-center justify-between p-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center text-muted-foreground">
|
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center text-muted-foreground relative">
|
||||||
<deviceInfo.Icon className="w-5 h-5"/>
|
<deviceInfo.Icon className="w-5 h-5" />
|
||||||
</div>
|
{provider && (
|
||||||
<div className="space-y-0.5">
|
<div className="absolute -bottom-1 -right-1 w-5 h-5 rounded-full bg-background border flex items-center justify-center overflow-hidden">
|
||||||
<div className="text-sm font-medium flex items-center gap-2">
|
{provider.icon.startsWith("/") ||
|
||||||
{deviceInfo.os} <span
|
provider.icon.startsWith("http") ? (
|
||||||
className="text-muted-foreground font-normal">• {deviceInfo.browser}</span>
|
<Image
|
||||||
{session.id === currentSession.id && (
|
src={provider.icon}
|
||||||
<Badge
|
alt={provider.id}
|
||||||
variant="outline"
|
width={12}
|
||||||
className="text-[10px] h-5 px-1.5 text-sky-600 bg-sky-50 border-sky-200 dark:bg-sky-900/20 dark:border-sky-800 dark:text-sky-400"
|
height={12}
|
||||||
>
|
className="w-3 h-3"
|
||||||
This device
|
unoptimized={provider.icon.startsWith("http")}
|
||||||
</Badge>
|
/>
|
||||||
)}
|
) : (
|
||||||
</div>
|
<Icon icon={provider.icon} className="w-3 h-3" />
|
||||||
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
)}
|
||||||
<Globe className="w-3 h-3"/> {session.ipAddress} •
|
|
||||||
<span className="ml-1">
|
|
||||||
{session.id === currentSession.id
|
|
||||||
? "Active now"
|
|
||||||
: `Last active ${timeAgo(new Date(session.createdAt))}`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{session.id !== currentSession.id && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
|
||||||
onClick={() => onRevoke(session.token)}
|
|
||||||
disabled={isRevoking}
|
|
||||||
>
|
|
||||||
{isRevoking ? <Loader2 className="h-4 w-4 animate-spin"/> : <LogOut className="w-4 h-4"/>}
|
|
||||||
<span className="sr-only">Revoke</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
<div className="space-y-0.5">
|
||||||
|
<div className="text-sm font-medium flex items-center gap-2">
|
||||||
|
{deviceInfo.os}{" "}
|
||||||
|
<span className="text-muted-foreground font-normal">
|
||||||
|
• {deviceInfo.browser}
|
||||||
|
</span>
|
||||||
|
{provider && (
|
||||||
|
<span className="text-muted-foreground font-normal">
|
||||||
|
• {provider.title || provider.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{session.id === currentSession.id && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px] h-5 px-1.5 text-sky-600 bg-sky-50 border-sky-200 dark:bg-sky-900/20 dark:border-sky-800 dark:text-sky-400"
|
||||||
|
>
|
||||||
|
This device
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<Globe className="w-3 h-3" /> {session.ipAddress} •
|
||||||
|
<span className="ml-1">
|
||||||
|
{session.id === currentSession.id
|
||||||
|
? "Active now"
|
||||||
|
: `Last active ${timeAgo(new Date(session.createdAt))}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{session.id !== currentSession.id && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => onRevoke(session.token)}
|
||||||
|
disabled={isRevoking}
|
||||||
|
>
|
||||||
|
{isRevoking ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
<span className="sr-only">Revoke</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PasskeyRow({
|
||||||
|
passkey,
|
||||||
|
onRevoke,
|
||||||
|
isRevoking,
|
||||||
|
}: {
|
||||||
|
passkey: any;
|
||||||
|
onRevoke: (id: string) => void;
|
||||||
|
isRevoking: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between p-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center text-muted-foreground">
|
||||||
|
<Fingerprint className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="font-medium text-sm">
|
||||||
|
{passkey.name || "Unnamed Passkey"}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Created {timeAgo(new Date(passkey.createdAt))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => onRevoke(passkey.id)}
|
||||||
|
disabled={isRevoking}
|
||||||
|
>
|
||||||
|
{isRevoking ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
<span className="sr-only">Revoke</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {useMutation, useQueryClient} from "@tanstack/react-query";
|
|||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
import {MemberWithUser} from "@/db/schema/03_organization";
|
import {MemberWithUser} from "@/db/schema/03_organization";
|
||||||
import {deleteBackupAction} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.action";
|
import {deleteBackupAction} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.action";
|
||||||
import { ButtonWithConfirm } from "@/components/wrappers/common/button/button-with-confirm";
|
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
|
||||||
|
|
||||||
|
|
||||||
type DatabaseBackupListProps = {
|
type DatabaseBackupListProps = {
|
||||||
@@ -69,19 +69,12 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
|
|||||||
mutationFn: async (backups: Backup[]) => {
|
mutationFn: async (backups: Backup[]) => {
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
backups.map(async (backup) => {
|
backups.map(async (backup) => {
|
||||||
if (backup.deletedAt == null) {
|
if (backup.deletedAt == null || backup.status == "ongoing") {
|
||||||
|
|
||||||
const backupDeleted = await deleteBackupAction({
|
const backupDeleted = await deleteBackupAction({
|
||||||
databaseId: backup.databaseId,
|
databaseId: backup.databaseId,
|
||||||
backupId: backup.id,
|
backupId: backup.id,
|
||||||
})
|
})
|
||||||
// const backupDeleted = await deleteBackupAction({
|
|
||||||
// backupId: backup.id,
|
|
||||||
// databaseId: backup.databaseId,
|
|
||||||
// status: backup.status,
|
|
||||||
// file: backup.file ?? "",
|
|
||||||
// projectSlug: props.database?.project?.slug!
|
|
||||||
// });
|
|
||||||
return {
|
return {
|
||||||
success: backupDeleted?.data?.success,
|
success: backupDeleted?.data?.success,
|
||||||
message: backupDeleted?.data?.success
|
message: backupDeleted?.data?.success
|
||||||
@@ -136,15 +129,9 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
|
|||||||
>Actions</ButtonWithLoading>
|
>Actions</ButtonWithLoading>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start">
|
<DropdownMenuContent align="start">
|
||||||
<ButtonWithConfirm
|
<ButtonWithConfirm
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
const backupsToDelete = rows.map(row => row
|
mutationDeleteBackups.mutate(rows);
|
||||||
).filter(backup => backup.deletedAt == null)
|
|
||||||
if (backupsToDelete.length === 0) {
|
|
||||||
toast.error("No available backup selected for deletion.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
mutationDeleteBackups.mutate(backupsToDelete);
|
|
||||||
setIsActionsOpen(false);
|
setIsActionsOpen(false);
|
||||||
}}
|
}}
|
||||||
onCancel={() => setIsActionsOpen(false)}
|
onCancel={() => setIsActionsOpen(false)}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user