mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e5d6eec6c | ||
|
|
92f1400a35 | ||
|
|
47f1938136 | ||
|
|
f226218b3a | ||
|
|
8f7fa0dea1 | ||
|
|
292b3c805d | ||
|
|
ded26f92af | ||
|
|
8cce7bbaf4 | ||
|
|
228b8c3ec7 | ||
|
|
2e298ce757 | ||
|
|
81daa9f0b0 | ||
|
|
713c8d9177 | ||
|
|
f571162eed | ||
|
|
19d2872943 | ||
|
|
0752b8bf7f | ||
|
|
c01097196a | ||
|
|
5f1bee018d | ||
|
|
8655b81618 | ||
|
|
1702e43373 | ||
|
|
a7d411f6ff | ||
|
|
6a84da0f96 | ||
|
|
79425f4490 | ||
|
|
2fcddea0fa | ||
|
|
ac1c8552e1 | ||
|
|
6a18eefe34 | ||
|
|
a43eedaf9f | ||
|
|
77bc24990e | ||
|
|
edbc79c89e | ||
|
|
527a090f1b | ||
|
|
370b86337f | ||
|
|
1ce3acdde0 | ||
|
|
1a72be323b | ||
|
|
bf252f22f2 | ||
|
|
937d92f686 | ||
|
|
5572957731 | ||
|
|
d2db5722b7 | ||
|
|
5404185a6c | ||
|
|
800f2dfbbb | ||
|
|
ed0b5410f6 | ||
|
|
ae72e262aa | ||
|
|
c4c7050c3f | ||
|
|
81e5227270 | ||
|
|
39319d9162 | ||
|
|
12206fa856 | ||
|
|
9d8c6e26f1 | ||
|
|
eb624672e6 | ||
|
|
1b026b5f08 | ||
|
|
21b9b8b44d | ||
|
|
6ef7fcc0c2 | ||
|
|
fc4ce178cb | ||
|
|
98b72988c4 | ||
|
|
317473e528 | ||
|
|
e13c5ddf51 | ||
|
|
90be3ab731 | ||
|
|
85c0445a64 | ||
|
|
6a227ccd7c | ||
|
|
3d9a21f854 | ||
|
|
d4229a985d | ||
|
|
c6e0921dea | ||
|
|
346a8a4375 | ||
|
|
4799faa827 | ||
|
|
2c6f5c38ed | ||
|
|
4a884ab5cc | ||
|
|
e59beaf749 | ||
|
|
0d4e1229ab | ||
|
|
8810fd91c9 | ||
|
|
095444610d | ||
|
|
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 | ||
|
|
3bf731055f |
+38
-8
@@ -1,10 +1,10 @@
|
||||
# Environnement
|
||||
# Environment
|
||||
NODE_ENV=development
|
||||
|
||||
# Base de données
|
||||
# Database
|
||||
DATABASE_URL=postgresql://devuser:changeme@localhost:5433/devdb?schema=public
|
||||
|
||||
# Projet
|
||||
# Project
|
||||
PROJECT_NAME="Portabase"
|
||||
PROJECT_DESCRIPTION="Portabase is a powerful database manager"
|
||||
PROJECT_URL=http://localhost:8887
|
||||
@@ -21,11 +21,41 @@ SMTP_FROM=
|
||||
#Port 465 -> secure: true
|
||||
#By default : true
|
||||
|
||||
# Google
|
||||
AUTH_GOOGLE_ID=
|
||||
AUTH_GOOGLE_SECRET=
|
||||
AUTH_GOOGLE_METHOD=
|
||||
# OIDC SSO Authentification
|
||||
AUTH_OIDC_ID=""
|
||||
AUTH_OIDC_TITLE=""
|
||||
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_CRON="* * * * *"
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ If you encounter a bug or have a suggestion for improvement, follow these steps:
|
||||
|
||||
3. **Push your branch**
|
||||
```bash
|
||||
git push origin feature/<feature-name>
|
||||
git push origin feat/<feature-name>
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
- 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.
|
||||
- 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! 🙌
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
#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: Push tags
|
||||
# run: git push origin --tags
|
||||
#
|
||||
# - name: Run release-it
|
||||
# run: |
|
||||
# git pull origin main
|
||||
# pnpm exec release-it --ci
|
||||
# env:
|
||||
# GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
|
||||
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: Bump version and tag
|
||||
run: pnpm exec release-it --ci --no-github
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Push commits and tags
|
||||
run: |
|
||||
git push origin main
|
||||
git push origin --tags
|
||||
|
||||
- name: Create GitHub release
|
||||
run: pnpm exec release-it --ci --only-github
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
@@ -1,16 +1,8 @@
|
||||
name: GitHub Release
|
||||
name: Discord Notification
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
prerelease:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
make_latest:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
discord_title:
|
||||
required: true
|
||||
type: string
|
||||
@@ -27,74 +19,24 @@ on:
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
notify-discord:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Build Changelog
|
||||
id: build_changelog
|
||||
uses: mikepenz/release-changelog-builder-action@v5
|
||||
with:
|
||||
mode: "COMMIT"
|
||||
configurationJson: |
|
||||
{
|
||||
"template": "#{{CHANGELOG}}",
|
||||
"categories": [
|
||||
{
|
||||
"title": "## Feature",
|
||||
"labels": ["feat", "feature"]
|
||||
},
|
||||
{
|
||||
"title": "## Fix",
|
||||
"labels": ["fix", "bug"]
|
||||
},
|
||||
{
|
||||
"title": "## Other",
|
||||
"labels": []
|
||||
}
|
||||
],
|
||||
"label_extractor": [
|
||||
{
|
||||
"pattern": "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test){1}(\([\w\-\.]+\))?(!)?: ([\w ])+([\s\S]*)",
|
||||
"on_property": "title",
|
||||
"target": "$1"
|
||||
}
|
||||
]
|
||||
}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: false
|
||||
body: ${{ steps.build_changelog.outputs.changelog }}
|
||||
prerelease: ${{ inputs.prerelease }}
|
||||
make_latest: ${{ inputs.make_latest }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
- name: Send Discord Notification
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
run: |
|
||||
RELEASE_INFO=$(gh release view "${{ github.ref_name }}" -R ${{ github.repository }} --json name,url,body,author)
|
||||
|
||||
|
||||
RELEASE_TITLE=$(echo "$RELEASE_INFO" | jq -r .name)
|
||||
if [ -z "$RELEASE_TITLE" ] || [ "$RELEASE_TITLE" = "null" ]; then RELEASE_TITLE="${{ github.ref_name }}"; fi
|
||||
|
||||
|
||||
RELEASE_URL=$(echo "$RELEASE_INFO" | jq -r .url)
|
||||
RELEASE_BODY=$(echo "$RELEASE_INFO" | jq -r .body)
|
||||
|
||||
|
||||
AUTHOR_NAME="Portabase"
|
||||
AUTHOR_ICON="https://github.com/Portabase.png"
|
||||
AUTHOR_ICON="https://github.com/Portabase.png"
|
||||
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg title "$RELEASE_TITLE" \
|
||||
@@ -105,7 +47,7 @@ jobs:
|
||||
--arg discord_title "${{ inputs.discord_title }}" \
|
||||
--arg discord_footer "${{ inputs.discord_footer }}" \
|
||||
--argjson discord_color ${{ inputs.discord_color }} \
|
||||
'{
|
||||
'{
|
||||
content: $discord_title,
|
||||
embeds: [{
|
||||
title: $title,
|
||||
@@ -121,8 +63,8 @@ jobs:
|
||||
}
|
||||
}]
|
||||
}'
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
curl -H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" \
|
||||
"$DISCORD_WEBHOOK"
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
image_name:
|
||||
required: false
|
||||
type: string
|
||||
default: 'portabase/portabase'
|
||||
default: "portabase/portabase"
|
||||
add_latest:
|
||||
required: false
|
||||
type: boolean
|
||||
@@ -14,11 +14,11 @@ on:
|
||||
target:
|
||||
required: false
|
||||
type: string
|
||||
default: 'prod'
|
||||
default: "prod"
|
||||
dockerfile:
|
||||
required: false
|
||||
type: string
|
||||
default: './docker/dockerfile/Dockerfile'
|
||||
default: "./docker/dockerfile/Dockerfile"
|
||||
secrets:
|
||||
DOCKER_USERNAME:
|
||||
required: true
|
||||
@@ -26,34 +26,32 @@ on:
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
|
||||
build:
|
||||
name: Build and push Docker images
|
||||
runs-on: ${{ matrix.platform == 'linux/amd64' && 'ubuntu-latest' || matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' }}
|
||||
name: Build architectures
|
||||
runs-on: ${{ matrix.platform == 'linux/amd64' && 'ubuntu-latest' || 'ubuntu-24.04-arm' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform: [ linux/amd64, linux/arm64 ]
|
||||
platform: [linux/amd64, linux/arm64]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker
|
||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Set image tag
|
||||
id: set-tags
|
||||
- name: Prepare Image Tags
|
||||
id: prep
|
||||
run: |
|
||||
REF_NAME=${GITHUB_REF#refs/tags/}
|
||||
if [ "${{ matrix.platform }}" = "linux/amd64" ]; then
|
||||
IMAGE="${{ inputs.image_name }}:$REF_NAME-amd64"
|
||||
else
|
||||
IMAGE="${{ inputs.image_name }}:$REF_NAME-arm64"
|
||||
fi
|
||||
echo "image=$IMAGE" >> $GITHUB_OUTPUT
|
||||
ARCH=${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
|
||||
echo "image=${{ inputs.image_name }}:${GITHUB_REF#refs/tags/}-$ARCH" >> $GITHUB_OUTPUT
|
||||
echo "safe_platform=${{ matrix.platform == 'linux/amd64' && 'linux-amd64' || 'linux-arm64' }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -62,61 +60,55 @@ jobs:
|
||||
file: ${{ inputs.dockerfile }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: true
|
||||
tags: ${{ steps.set-tags.outputs.image }}
|
||||
tags: ${{ steps.prep.outputs.image }}
|
||||
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
|
||||
id: artifact
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "safe_platform=${platform//\//-}" >> $GITHUB_OUTPUT
|
||||
echo "${{ steps.set-tags.outputs.image }}" > image.txt
|
||||
- name: Save image name for manifest
|
||||
run: echo "${{ steps.prep.outputs.image }}" > image.txt
|
||||
|
||||
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: image-${{ steps.artifact.outputs.safe_platform }}
|
||||
name: image-${{ steps.prep.outputs.safe_platform }}
|
||||
path: image.txt
|
||||
if-no-files-found: warn
|
||||
compression-level: 6
|
||||
overwrite: false
|
||||
include-hidden-files: false
|
||||
retention-days: 1
|
||||
|
||||
create-manifest:
|
||||
name: Create multi-arch Docker manifest (Portabase)
|
||||
name: Create multi-arch manifest
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: image-linux-amd64
|
||||
path: /tmp/digests/amd64
|
||||
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: image-linux-arm64
|
||||
path: /tmp/digests/arm64
|
||||
|
||||
- name: Login to Docker
|
||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
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
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
DOCKER_IMAGES="$(cat amd64/image.txt) $(cat arm64/image.txt)"
|
||||
REF_NAME=${GITHUB_REF#refs/tags/}
|
||||
MANIFEST_IMAGE="${{ inputs.image_name }}:$REF_NAME"
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
||||
|
||||
TAG_ARGS=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON")
|
||||
docker buildx imagetools create $DOCKER_IMAGES $TAG_ARGS
|
||||
|
||||
@@ -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:
|
||||
push:
|
||||
tags:
|
||||
- '*-rc*'
|
||||
- "*.*.*-rc*"
|
||||
- "!*-*-rc*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
docker_publish:
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
add_latest: false
|
||||
add_latest: true
|
||||
secrets:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }}
|
||||
|
||||
github_release:
|
||||
discord_notification:
|
||||
needs: docker_publish
|
||||
uses: ./.github/workflows/github.yml
|
||||
uses: ./.github/workflows/discord.yml
|
||||
with:
|
||||
prerelease: true
|
||||
make_latest: false
|
||||
discord_title: "||@release-dashboard|| New release candidate published"
|
||||
discord_color: 16776960
|
||||
discord_title: "New Release Candidate: ${{ github.ref_name }}"
|
||||
discord_color: 2044800
|
||||
discord_footer: "Portabase"
|
||||
secrets:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
|
||||
@@ -3,11 +3,11 @@ name: Publish Docker image for release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*.*.*'
|
||||
- '!*-*'
|
||||
- "*.*.*"
|
||||
- "!*-*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
@@ -18,16 +18,28 @@ jobs:
|
||||
secrets:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }}
|
||||
|
||||
github_release:
|
||||
publish_release:
|
||||
needs: docker_publish
|
||||
uses: ./.github/workflows/github.yml
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Publish GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release edit v${{ github.ref_name }} --draft=false
|
||||
|
||||
discord_notification:
|
||||
needs: docker_publish
|
||||
uses: ./.github/workflows/discord.yml
|
||||
with:
|
||||
prerelease: false
|
||||
make_latest: true
|
||||
discord_title: "||@release-dashboard|| New release published"
|
||||
discord_color: 5814783
|
||||
discord_title: "New Release: v${{ github.ref_name }}"
|
||||
discord_color: 3066993
|
||||
discord_footer: "Portabase"
|
||||
secrets:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
sca-deps: # Dependency & container scan
|
||||
sca-deps:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -17,15 +17,16 @@ jobs:
|
||||
ignore-unfixed: true
|
||||
|
||||
|
||||
secrets-gitleaks: # Secrets exposure
|
||||
secrets-gitleaks:
|
||||
if: github.event.pull_request.head.repo.fork == false
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Fetch full history for gitleaks
|
||||
fetch-depth: 0
|
||||
- uses: gitleaks/gitleaks-action@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
|
||||
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
|
||||
|
||||
certificates
|
||||
seeds/pocket-id/*.db
|
||||
|
||||
certificates
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"github": {
|
||||
"release": true,
|
||||
"draft": true,
|
||||
"tokenRef": "GITHUB_TOKEN"
|
||||
},
|
||||
"git": {
|
||||
"commit": true,
|
||||
"commitMessage": "chore: release v${version}",
|
||||
"requireCleanWorkingDir": true,
|
||||
"tag": true,
|
||||
"tagName": "v${version}",
|
||||
"push": true
|
||||
},
|
||||
"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
|
||||
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
|
||||
authors:
|
||||
- family-names: Gauthereau
|
||||
@@ -9,7 +9,12 @@ authors:
|
||||
given-names: Killian
|
||||
repository-code: https://github.com/Portabase/portabase
|
||||
url: https://portabase.io
|
||||
abstract: "Portabase is a free, open-source, self-hosted solution for database administration, providing backup and restore capabilities, scheduling, retention policies, notifications, and support for multiple storage backends. Its headless agent architecture enables connection to multiple database instances securely and efficiently."
|
||||
abstract: >-
|
||||
Portabase is a free, open-source, self-hosted solution for database
|
||||
administration, providing backup and restore capabilities, scheduling,
|
||||
retention policies, notifications, and support for multiple storage backends.
|
||||
Its headless agent architecture enables connection to multiple database
|
||||
instances securely and efficiently.
|
||||
keywords:
|
||||
- docker
|
||||
- kubernetes
|
||||
@@ -26,5 +31,5 @@ keywords:
|
||||
- web-ui
|
||||
- agent
|
||||
license: Apache-2.0
|
||||
version: 1.2.7
|
||||
date-released: "2026-02-16"
|
||||
version: 1.4.27
|
||||
date-released: '2026-03-02'
|
||||
|
||||
@@ -1,4 +1,48 @@
|
||||
CLUSTER_SCRIPT=docker/entrypoints/app-dev-entrypoint.sh
|
||||
|
||||
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"
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
[](https://www.postgresql.org/)
|
||||
[](https://www.mysql.com/)
|
||||
[](https://mariadb.org/)
|
||||
[](https://sqlite.org/)
|
||||
[](https://www.mongodb.com/)
|
||||
[](https://github.com/Portabase/portabase)
|
||||
[](https://github.com/Portabase/portabase)
|
||||
@@ -33,14 +34,14 @@
|
||||
<strong>
|
||||
<a href="https://portabase.io">Website</a> •
|
||||
<a href="https://portabase.io/docs">Documentation</a> •
|
||||
<a href="https://www.youtube.com/watch?v=hvLbX5LN1UE">Demo</a> •
|
||||
<a href="https://www.youtube.com/watch?v=nSTzT27GgAg">Demo</a> •
|
||||
<a href="https://portabase.io/docs/dashboard/setup">Installation</a> •
|
||||
<a href="https://github.com/Portabase/portabase/issues/new?labels=bug&template=bug-report---.md">Report Bug</a> •
|
||||
<a href="https://github.com/Portabase/portabase/issues/new?labels=enhancement&template=feature-request---.md">Request Feature</a>
|
||||
</strong>
|
||||
</p>
|
||||
|
||||

|
||||

|
||||
|
||||
|
||||
</div>
|
||||
@@ -87,4 +88,3 @@ Distributed under the Apache License. See `LICENSE.txt` for more details.
|
||||
[ShadcnUI-url]: https://ui.shadcn.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 {ForgotPasswordForm} from "@/components/wrappers/auth/login/forgot-password-form/forgot-password-form";
|
||||
import {CardAuth} from "@/features/layout/card-auth";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { ForgotPasswordForm } from "@/components/wrappers/auth/login/forgot-password-form/forgot-password-form";
|
||||
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 }> }) {
|
||||
if (env.AUTH_EMAIL_PASSWORD_ENABLED !== "true") {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
@@ -12,12 +17,11 @@ export default async function RoutePage(props: { searchParams: Promise<{ callbac
|
||||
<CardHeader>
|
||||
<div className="grid gap-2 text-center mb-2">
|
||||
<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
|
||||
link to reset your password.</p>
|
||||
<p className="text-balance text-muted-foreground">Enter your email address and we'll send you a link to reset your password.</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ForgotPasswordForm/>
|
||||
<ForgotPasswordForm />
|
||||
</CardContent>
|
||||
</CardAuth>
|
||||
</TooltipProvider>
|
||||
|
||||
+34
-28
@@ -1,32 +1,38 @@
|
||||
import React from "react";
|
||||
import {redirect} from "next/navigation";
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
import {AuthLogoSection} from "@/components/wrappers/auth/auth-logo-section";
|
||||
import {env} from "@/env.mjs";
|
||||
import {Heart} from "lucide-react";
|
||||
import React, { Suspense } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
import { AuthLogoSection } from "@/components/wrappers/auth/auth-logo-section";
|
||||
import { env } from "@/env.mjs";
|
||||
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") {
|
||||
redirect("/dashboard/home");
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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>
|
||||
return (
|
||||
<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">
|
||||
<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 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 {Metadata} from "next";
|
||||
import {SUPPORTED_PROVIDERS} from "../../../portabase.config";
|
||||
import {SocialAuthButtons} from "@/components/wrappers/auth/social-buttons";
|
||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
||||
import {CardContent, CardHeader} from "@/components/ui/card";
|
||||
import { LoginForm } from "@/components/wrappers/auth/login/login-form/login-form";
|
||||
import { Metadata } from "next";
|
||||
import { SUPPORTED_PROVIDERS } from "@/lib/auth/config";
|
||||
import { SocialAuthButtons } from "@/components/wrappers/auth/social-buttons";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { CardContent, CardHeader } from "@/components/ui/card";
|
||||
import Link from "next/link";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {CardAuth} from "@/features/layout/card-auth";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { CardAuth } from "@/features/layout/card-auth";
|
||||
import { env } from "@/env.mjs";
|
||||
import { en } from "zod/v4/locales";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Login",
|
||||
title: "Login",
|
||||
};
|
||||
|
||||
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 (
|
||||
<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">
|
||||
<LoginForm/>
|
||||
{env.AUTH_EMAIL_PASSWORD_ENABLED === "true" &&
|
||||
SUPPORTED_PROVIDERS.filter((p) => !p.isManual && p.isActive)
|
||||
.length > 0 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{SUPPORTED_PROVIDERS.filter((p) => !p.isManual && p.isActive).length > 0 && (
|
||||
<>
|
||||
<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}/>
|
||||
</>
|
||||
)}
|
||||
{SUPPORTED_PROVIDERS.filter((p) => !p.isManual && p.isActive).length >
|
||||
0 && <SocialAuthButtons providers={SUPPORTED_PROVIDERS} />}
|
||||
|
||||
<div className="mt-4 text-center text-sm">
|
||||
Don't have an account ?{" "}
|
||||
<Link href="/register" className="underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</CardAuth>
|
||||
</TooltipProvider>
|
||||
|
||||
|
||||
)
|
||||
{env.AUTH_SIGNUP_ENABLED !== "true" ||
|
||||
(env.AUTH_EMAIL_PASSWORD_ENABLED === "true" && (
|
||||
<div className="mt-4 text-center text-sm">
|
||||
Don't have an account ?{" "}
|
||||
<Link href="/register" className="underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</CardAuth>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {RegisterForm} from "@/components/wrappers/auth/register/register-form/register-form";
|
||||
import {Metadata} from "next";
|
||||
import { PageParams } from "@/types/next";
|
||||
import { RegisterForm } from "@/components/wrappers/auth/register/register-form/register-form";
|
||||
import { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { env } from "@/env.mjs";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Register",
|
||||
title: "Register",
|
||||
};
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
return (
|
||||
<div className="mx-auto grid w-full gap-6">
|
||||
<RegisterForm/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (
|
||||
env.AUTH_SIGNUP_ENABLED !== "true" ||
|
||||
env.AUTH_EMAIL_PASSWORD_ENABLED !== "true"
|
||||
) {
|
||||
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 { auth } from "@/lib/auth/auth";
|
||||
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 }> }) {
|
||||
if (env.AUTH_EMAIL_PASSWORD_ENABLED !== "true") {
|
||||
return redirect("/login");
|
||||
}
|
||||
|
||||
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 {AdminUserList} from "@/components/wrappers/dashboard/admin/users/admin-user-list";
|
||||
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<{}>) {
|
||||
|
||||
@@ -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 (
|
||||
<Page>
|
||||
<PageHeader className="flex flex-col">
|
||||
@@ -32,7 +36,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
</div>
|
||||
</PageHeader>
|
||||
<PageContent className="flex flex-col gap-5">
|
||||
<AdminUserList users={users}/>
|
||||
<AdminUserList users={users} isPasswordAuthEnabled={isPasswordAuthEnabled}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -1,61 +1,69 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
||||
import {db} from "@/db";
|
||||
import { PageParams } from "@/types/next";
|
||||
import {
|
||||
Page,
|
||||
PageContent,
|
||||
PageDescription,
|
||||
PageTitle,
|
||||
} from "@/features/layout/page";
|
||||
import { db } from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {notFound} from "next/navigation";
|
||||
import {ButtonDeleteAgent} from "@/components/wrappers/dashboard/agent/button-delete-agent/button-delete-agent";
|
||||
import {capitalizeFirstLetter} from "@/utils/text";
|
||||
import {generateEdgeKey} from "@/utils/edge_key";
|
||||
import {getServerUrl} from "@/utils/get-server-url";
|
||||
import {AgentContentPage} from "@/components/wrappers/dashboard/agent/agent-content";
|
||||
import {AgentDialog} from "@/features/agents/components/agent.dialog";
|
||||
import {AgentType} from "@/features/agents/agents.schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ButtonDeleteAgent } from "@/components/wrappers/dashboard/agent/button-delete-agent/button-delete-agent";
|
||||
import { capitalizeFirstLetter } from "@/utils/text";
|
||||
import { generateEdgeKey } from "@/utils/edge_key";
|
||||
import { getServerUrl } from "@/utils/get-server-url";
|
||||
import { AgentContentPage } from "@/components/wrappers/dashboard/agent/agent-content";
|
||||
import { AgentDialog } from "@/features/agents/components/agent.dialog";
|
||||
import { AgentType } from "@/features/agents/agents.schema";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{ agentId: string }>) {
|
||||
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({
|
||||
where: eq(drizzleDb.schemas.agent.id, agentId),
|
||||
with: {
|
||||
databases: true
|
||||
}
|
||||
})
|
||||
if (!agent) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (!agent) {
|
||||
notFound()
|
||||
}
|
||||
const edgeKey = await generateEdgeKey(getServerUrl(), agent.id);
|
||||
|
||||
const edgeKey = await generateEdgeKey(getServerUrl(), agent.id);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="justify-between gap-2 sm:flex">
|
||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||
<div className="min-w-full md:min-w-fit ">
|
||||
{capitalizeFirstLetter(agent.name)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||
<div className="flex items-center gap-2">
|
||||
<AgentDialog agent={agent as AgentType & { id: string }} typeTrigger={"edit"}/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"}/>
|
||||
</div>
|
||||
</div>
|
||||
</PageTitle>
|
||||
return (
|
||||
<Page>
|
||||
<div className="justify-between gap-2 sm:flex">
|
||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||
<div className="min-w-full md:min-w-fit ">
|
||||
{capitalizeFirstLetter(agent.name)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||
<div className="flex items-center gap-2">
|
||||
<AgentDialog
|
||||
agent={agent as AgentType & { id: string }}
|
||||
typeTrigger={"edit"}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"} />
|
||||
</div>
|
||||
</div>
|
||||
</PageTitle>
|
||||
</div>
|
||||
|
||||
{agent.description && (
|
||||
<PageDescription className="mt-5 sm:mt-0">{agent.description}</PageDescription>
|
||||
)}
|
||||
<PageContent className="flex flex-col w-full h-full justify-between gap-6">
|
||||
<AgentContentPage
|
||||
agent={agent}
|
||||
edgeKey={edgeKey}
|
||||
/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
{agent.description && (
|
||||
<PageDescription className="mt-5 sm:mt-0">
|
||||
{agent.description}
|
||||
</PageDescription>
|
||||
)}
|
||||
<PageContent className="flex flex-col w-full h-full justify-between gap-6">
|
||||
<AgentContentPage agent={agent} edgeKey={edgeKey} />
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
-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 {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
||||
import {notFound} from "next/navigation";
|
||||
import {Metadata} from "next";
|
||||
import {OrganizationTabs} from "@/components/wrappers/dashboard/organization/tabs/organization-tabs";
|
||||
import {getOrganizationChannels} from "@/db/services/notification-channel";
|
||||
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
|
||||
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
|
||||
import {DeleteOrganizationButton} from "@/components/wrappers/dashboard/organization/delete-organization-button";
|
||||
import {EditOrganizationDialog} from "@/features/organization/components/edit-organization.dialog";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {GearIcon} from "@radix-ui/react-icons";
|
||||
import {db} from "@/db";
|
||||
import {isNull} from "drizzle-orm";
|
||||
import { PageParams } from "@/types/next";
|
||||
import {
|
||||
Page,
|
||||
PageActions,
|
||||
PageContent,
|
||||
PageHeader,
|
||||
PageTitle,
|
||||
} from "@/features/layout/page";
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
import { getActiveMember, getOrganization } from "@/lib/auth/auth";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Metadata } from "next";
|
||||
import { OrganizationTabs } from "@/components/wrappers/dashboard/organization/tabs/organization-tabs";
|
||||
import { getOrganizationChannels } from "@/db/services/notification-channel";
|
||||
import { computeOrganizationPermissions } from "@/lib/acl/organization-acl";
|
||||
import { getOrganizationStorageChannels } from "@/db/services/storage-channel";
|
||||
import { DeleteOrganizationButton } from "@/components/wrappers/dashboard/organization/delete-organization-button";
|
||||
import { EditOrganizationDialog } from "@/features/organization/components/edit-organization.dialog";
|
||||
import { db } from "@/db";
|
||||
import { isNull } from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Settings",
|
||||
title: "Settings",
|
||||
};
|
||||
|
||||
export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
||||
const organization = await getOrganization({});
|
||||
const user = await currentUser();
|
||||
const activeMember = await getActiveMember()
|
||||
const organization = await getOrganization({});
|
||||
const user = await currentUser();
|
||||
const activeMember = await getActiveMember();
|
||||
|
||||
if (!organization || !activeMember || !user) {
|
||||
notFound();
|
||||
}
|
||||
if (!organization || !activeMember || !user) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const notificationChannels = await getOrganizationChannels(organization.id)
|
||||
const storageChannels = await getOrganizationStorageChannels(organization.id)
|
||||
const permissions = computeOrganizationPermissions(activeMember);
|
||||
const notificationChannels = await getOrganizationChannels(organization.id);
|
||||
const storageChannels = await getOrganizationStorageChannels(organization.id);
|
||||
const permissions = computeOrganizationPermissions(activeMember);
|
||||
|
||||
const users = await db.query.user.findMany({
|
||||
where: (fields) => isNull(fields.deletedAt)
|
||||
});
|
||||
const users = await db.query.user.findMany({
|
||||
where: (fields) => isNull(fields.deletedAt),
|
||||
});
|
||||
|
||||
const organizationWithMembers = await db.query.organization.findFirst({
|
||||
where: eq(drizzleDb.schemas.organization.id, organization.id),
|
||||
const organizationWithMembers = await db.query.organization.findFirst({
|
||||
where: eq(drizzleDb.schemas.organization.id, organization.id),
|
||||
with: {
|
||||
members: {
|
||||
with: {
|
||||
members: {
|
||||
with: {
|
||||
user: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organizationWithMembers) notFound();
|
||||
if (!organizationWithMembers) notFound();
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||
<div className="min-w-full md:min-w-fit ">
|
||||
Organization settings
|
||||
</div>
|
||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||
<div className="flex items-center gap-2">
|
||||
{permissions.canManageSettings && organization.slug !== "default" && (
|
||||
<EditOrganizationDialog
|
||||
organization={organizationWithMembers}
|
||||
users={users}
|
||||
currentUser={user}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{permissions.canManageDangerZone && organization.slug !== "default" && (
|
||||
<DeleteOrganizationButton organizationSlug={organization.slug}/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageTitle>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<OrganizationTabs
|
||||
activeMember={activeMember}
|
||||
organization={organization}
|
||||
notificationChannels={notificationChannels}
|
||||
storageChannels={storageChannels}
|
||||
/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
)
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||
<div className="min-w-full md:min-w-fit ">Organization settings</div>
|
||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||
<div className="flex items-center gap-2">
|
||||
{permissions.canManageSettings &&
|
||||
organization.slug !== "default" && (
|
||||
<EditOrganizationDialog
|
||||
organization={organizationWithMembers}
|
||||
users={users}
|
||||
currentUser={user}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{permissions.canManageDangerZone &&
|
||||
organization.slug !== "default" && (
|
||||
<DeleteOrganizationButton
|
||||
organizationSlug={organization.slug}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageTitle>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<OrganizationTabs
|
||||
activeMember={activeMember}
|
||||
organization={organization}
|
||||
notificationChannels={notificationChannels}
|
||||
storageChannels={storageChannels}
|
||||
/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
import React, {ReactNode} from "react";
|
||||
import {redirect} from "next/navigation";
|
||||
import { ReactNode } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import {SidebarInset, SidebarProvider} from "@/components/ui/sidebar";
|
||||
import {AppSidebar} from "@/components/wrappers/dashboard/common/sidebar/app-sidebar";
|
||||
import {Header} from "@/features/layout/Header";
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
import {ThemeMetaUpdater} from "@/features/browser/theme-meta-updater";
|
||||
import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { AppSidebar } from "@/components/wrappers/dashboard/common/sidebar/app-sidebar";
|
||||
import { Header } from "@/features/layout/Header";
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
import { ThemeMetaUpdater } from "@/features/browser/theme-meta-updater";
|
||||
|
||||
export default async function Layout({children}: { children: ReactNode }) {
|
||||
const user = await currentUser();
|
||||
if (!user) redirect("/login");
|
||||
export default async function Layout({ children }: { children: ReactNode }) {
|
||||
const user = await currentUser();
|
||||
if (!user) redirect("/login");
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<div className="flex flex-col lg:flex-row w-full">
|
||||
<ThemeMetaUpdater/>
|
||||
<AppSidebar/>
|
||||
<SidebarInset>
|
||||
<Header/>
|
||||
<main className="h-full">{children}</main>
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<div className="flex flex-col lg:flex-row w-full">
|
||||
<ThemeMetaUpdater />
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<Header />
|
||||
<main className="h-full">{children}</main>
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,6 +102,7 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
|
||||
}) => {
|
||||
try {
|
||||
const body: BodyPatch = await request.json();
|
||||
console.log("body", body);
|
||||
|
||||
const status = body.status
|
||||
const backupId = body.backupId
|
||||
@@ -131,7 +132,7 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
|
||||
|
||||
|
||||
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(
|
||||
{
|
||||
|
||||
@@ -63,8 +63,7 @@ export async function POST(
|
||||
.set({status: body.status as RestorationStatus})
|
||||
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
||||
|
||||
|
||||
await sendNotificationsBackupRestore(database, body.status == "error" ? "error_restore" : "success_restore");
|
||||
await sendNotificationsBackupRestore(database, body.status == "failed" ? "error_restore" : "success_restore");
|
||||
|
||||
const response = {
|
||||
message: true,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {NextResponse} from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
PROJECT_URL: process.env.PROJECT_URL,
|
||||
PROJECT_NAME: process.env.PROJECT_NAME,
|
||||
PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION,
|
||||
});
|
||||
return NextResponse.json({
|
||||
PROJECT_URL: process.env.PROJECT_URL,
|
||||
PROJECT_NAME: process.env.PROJECT_NAME,
|
||||
PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION,
|
||||
});
|
||||
}
|
||||
|
||||
+30
-82
@@ -1,85 +1,33 @@
|
||||
// "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"
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import React, { Suspense } from "react"
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useSession } from "@/lib/auth/auth-client";
|
||||
|
||||
enum Error {
|
||||
Configuration = "Configuration",
|
||||
export default function ErrorPage() {
|
||||
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 {Providers} from "./providers";
|
||||
import {cn} from "@/lib/utils";
|
||||
import {ConsoleSilencer} from "@/components/wrappers/common/console-silencer";
|
||||
import {author, geistMono, poppins} from "@/fonts/fonts";
|
||||
import { ConsoleSilencer } from "@/components/wrappers/common/console-silencer";
|
||||
import { author, geistMono, poppins } from "@/fonts/fonts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Providers } from "./providers";
|
||||
|
||||
const title = process.env.PROJECT_NAME ?? "App Title";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: title,
|
||||
template: `%s - ${title}`
|
||||
},
|
||||
description: process.env.PROJECT_DESCRIPTION ?? undefined,
|
||||
title: {
|
||||
default: title,
|
||||
template: `%s - ${title}`,
|
||||
},
|
||||
description: process.env.PROJECT_DESCRIPTION ?? undefined,
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<meta name="apple-mobile-web-app-title" content={title}/>
|
||||
</head>
|
||||
<body className={cn(poppins.variable, author.variable, geistMono.variable, "font-sans h-full")}>
|
||||
<ConsoleSilencer/>
|
||||
<Providers>
|
||||
{children}
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<meta name="apple-mobile-web-app-title" content={title} />
|
||||
</head>
|
||||
<body
|
||||
className={cn(
|
||||
poppins.variable,
|
||||
author.variable,
|
||||
geistMono.variable,
|
||||
"font-sans h-full",
|
||||
)}
|
||||
>
|
||||
<ConsoleSilencer />
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
+14
-10
@@ -1,15 +1,19 @@
|
||||
import BackButton from "@/components/wrappers/common/button/back-button";
|
||||
|
||||
export default async function NotFound() {
|
||||
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="w-full space-y-6 text-center">
|
||||
<div className="space-y-3">
|
||||
<h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">Not found</h1>
|
||||
<p className="leading-7 [&:not(:first-child)]:mt-6">The content you are trying to view is not available.</p>
|
||||
</div>
|
||||
<BackButton>Go home</BackButton>
|
||||
</div>
|
||||
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="w-full space-y-6 text-center">
|
||||
<div className="space-y-3">
|
||||
<h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">
|
||||
Not found
|
||||
</h1>
|
||||
<p className="leading-7 not-first:mt-6">
|
||||
The content you are trying to view is not available.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
<BackButton>Go home</BackButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+19
-21
@@ -1,30 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import {PropsWithChildren, Suspense} from "react";
|
||||
import {ThemeProvider} from "@/features/theme/theme-provider";
|
||||
|
||||
import {Toaster} from "@/components/ui/sonner";
|
||||
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
|
||||
import {ThemeMetaUpdaterRoot} from "@/features/browser/theme-meta-updater-root";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { type PropsWithChildren, Suspense } from "react";
|
||||
|
||||
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<{}>;
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export const Providers = (props: ProviderProps) => {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
>
|
||||
<ThemeMetaUpdaterRoot/>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Toaster/>
|
||||
{props.children}
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</Suspense>
|
||||
);
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<ThemeMetaUpdaterRoot />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ErrorLayout>
|
||||
<Toaster />
|
||||
{props.children}
|
||||
</ErrorLayout>
|
||||
</QueryClientProvider>
|
||||
</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:
|
||||
@@ -4,7 +4,7 @@ services:
|
||||
# context: .
|
||||
# dockerfile: docker/dockerfile/Dockerfile
|
||||
# target: prod
|
||||
image: portabase/portabase:1.2.7-rc.1
|
||||
image: portabase/portabase:1.4.0
|
||||
ports:
|
||||
- '8887:80'
|
||||
environment:
|
||||
|
||||
+2
-1
@@ -12,7 +12,7 @@ services:
|
||||
- POSTGRES_USER=devuser
|
||||
- POSTGRES_PASSWORD=changeme
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -U devuser -d devdb" ]
|
||||
test: ["CMD-SHELL", "pg_isready -U devuser -d devdb"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -30,6 +30,7 @@ services:
|
||||
- "localhost:host-gateway"
|
||||
volumes:
|
||||
- ./private/uploads/tmp:/data/uploads/tmp
|
||||
user: "1000:1000"
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
#FROM node:22-bullseye AS base
|
||||
FROM --platform=$BUILDPLATFORM node:22-bullseye AS base
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
@@ -112,8 +111,6 @@ RUN chmod +x /usr/local/bin/tusd
|
||||
|
||||
COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
#RUN mkdir -p .next /app/private/uploads \
|
||||
# && chown -R nextjs:nodejs .next /app/private /app/public
|
||||
|
||||
RUN mkdir -p .next /data/private/uploads \
|
||||
&& chown -R nextjs:nodejs .next /data/private /app/public
|
||||
|
||||
+20
-16
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "portabase",
|
||||
"version": "1.2.7",
|
||||
"version": "1.4.27",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 8887",
|
||||
@@ -11,9 +11,12 @@
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"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": {
|
||||
"@better-auth/passkey": "^1.4.19",
|
||||
"@better-auth/sso": "^1.4.19",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
@@ -47,13 +50,12 @@
|
||||
"@t3-oss/env-nextjs": "^0.13.10",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/nodemailer": "^6.4.22",
|
||||
"@types/nodemailer": "^6.4.23",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@zenstackhq/runtime": "2.14.2",
|
||||
"argon2": "^0.43.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-auth": "1.4.5",
|
||||
"better-auth": "1.4.18",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -63,12 +65,11 @@
|
||||
"drizzle-orm": "^0.43.1",
|
||||
"drizzle-zod": "^0.7.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"express": "^5.2.1",
|
||||
"googleapis": "^170.1.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.553.0",
|
||||
"minio": "^8.0.6",
|
||||
"motion": "^12.34.0",
|
||||
"motion": "^12.34.3",
|
||||
"next": "16.1.5",
|
||||
"next-safe-action": "^7.10.8",
|
||||
"next-themes": "^0.4.6",
|
||||
@@ -83,7 +84,7 @@
|
||||
"react-dom": "^19.2.4",
|
||||
"react-dropzone": "^14.4.1",
|
||||
"react-email": "^4.3.2",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"react-qr-code": "^2.0.18",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-twc": "^1.5.1",
|
||||
@@ -93,8 +94,8 @@
|
||||
"socket.io": "^4.8.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"sonner": "^2.0.7",
|
||||
"swiper": "^12.1.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"swiper": "^12.1.2",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"uuid": "^11.1.0",
|
||||
"vaul": "^1.1.2",
|
||||
"ws": "^8.19.0",
|
||||
@@ -104,7 +105,9 @@
|
||||
"@iconify/react": "^6.0.2",
|
||||
"@react-email/preview-server": "4.3.2",
|
||||
"@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/node": "^22.19.11",
|
||||
"@types/node-forge": "^1.3.14",
|
||||
@@ -113,19 +116,20 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@zenstackhq/openapi": "^2.22.1",
|
||||
"@zenstackhq/tanstack-query": "^2.22.2",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"baseline-browser-mapping": "^2.10.0",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"esbuild": "^0.27.3",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint": "^9.39.3",
|
||||
"eslint-config-next": "^16.1.6",
|
||||
"eslint-plugin-tailwindcss": "^3.18.2",
|
||||
"framer-motion": "^12.34.0",
|
||||
"framer-motion": "^12.34.3",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"release-it": "^19.2.4",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tsx": "^4.21.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"zenstack": "2.14.2"
|
||||
},
|
||||
"packageManager": "pnpm@10.29.2+sha512.bef43fa759d91fd2da4b319a5a0d13ef7a45bb985a3d7342058470f9d2051a3ba8674e629672654686ef9443ad13a82da2beb9eeb3e0221c87b8154fff9d74b8"
|
||||
"packageManager": "pnpm@10.30.1+sha512.3590e550d5384caa39bd5c7c739f72270234b2f6059e13018f975c313b1eb9fefcc09714048765d4d9efe961382c312e624572c0420762bdc5d5940cdf9be73a"
|
||||
}
|
||||
|
||||
Generated
+2429
-869
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,3 @@
|
||||
export interface AuthProviderConfig {
|
||||
id: "google" | "github" | "credential";
|
||||
isActive: boolean;
|
||||
icon: string;
|
||||
isManual?: boolean;
|
||||
credentials?: {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export const PORTABASE_DEFAULT_SETTINGS = {
|
||||
@@ -74,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 {loggingMiddleware} from "@/middleware/loggingMiddleware";
|
||||
import {errorHandler} from "@/middleware/errorHandler";
|
||||
import {auth} from "@/lib/auth/auth";
|
||||
import {headers} from "next/headers";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { loggingMiddleware } from "@/middleware/loggingMiddleware";
|
||||
import { errorHandler } from "@/middleware/errorHandler";
|
||||
import { auth } from "@/lib/auth/auth";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export async function proxy(request: NextRequest) {
|
||||
const url = request.nextUrl.clone();
|
||||
const redirectUrl = encodeURIComponent(request.nextUrl.pathname)
|
||||
const url = request.nextUrl.clone();
|
||||
const redirectUrl = encodeURIComponent(request.nextUrl.pathname);
|
||||
|
||||
if (url.pathname.startsWith("/dashboard")) {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
});
|
||||
if (!session) {
|
||||
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 (url.pathname.startsWith("/dashboard")) {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
});
|
||||
if (!session) {
|
||||
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 (url.pathname.startsWith("/api/auth")) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
if (url.pathname.startsWith("/api/auth")) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api")) {
|
||||
const routeExists = checkRouteExists(url.pathname);
|
||||
if (!routeExists) {
|
||||
return new NextResponse(JSON.stringify({message: "This API route does not exist.", status: 404}), {
|
||||
status: 404,
|
||||
headers: {"Content-Type": "application/json"},
|
||||
});
|
||||
}
|
||||
}
|
||||
try {
|
||||
loggingMiddleware(request);
|
||||
} catch (err) {
|
||||
errorHandler(err);
|
||||
if (url.pathname.startsWith("/api")) {
|
||||
const routeExists = checkRouteExists(url.pathname);
|
||||
if (!routeExists) {
|
||||
return new NextResponse(
|
||||
JSON.stringify({
|
||||
message: "This API route does not exist.",
|
||||
status: 404,
|
||||
}),
|
||||
{
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
try {
|
||||
loggingMiddleware(request);
|
||||
} catch (err) {
|
||||
errorHandler(err);
|
||||
}
|
||||
}
|
||||
|
||||
function checkRouteExists(pathname: string) {
|
||||
const routePatterns = [
|
||||
/^\/api\/agent\/[^/]+\/status\/?$/,
|
||||
/^\/api\/agent\/[^/]+\/backup\/?$/,
|
||||
/^\/api\/agent\/[^/]+\/backup\/upload\/init\/?$/,
|
||||
/^\/api\/agent\/[^/]+\/backup\/upload\/status\/?$/,
|
||||
/^\/api\/agent\/[^/]+\/restore\/?$/,
|
||||
/^\/api\/files\/images\/[^/]+\/?$/,
|
||||
/^\/api\/files\/backups\/?$/,
|
||||
/^\/api\/tus\/hooks\/?$/,
|
||||
/^\/api\/events\/?$/,
|
||||
/^\/api\/config\/?$/,
|
||||
/^\/api\/google\/drive\/callback\/?$/,
|
||||
];
|
||||
return routePatterns.some((pattern) => pattern.test(pathname));
|
||||
const routePatterns = [
|
||||
/^\/api\/agent\/[^/]+\/status\/?$/,
|
||||
/^\/api\/agent\/[^/]+\/backup\/?$/,
|
||||
/^\/api\/agent\/[^/]+\/backup\/upload\/init\/?$/,
|
||||
/^\/api\/agent\/[^/]+\/backup\/upload\/status\/?$/,
|
||||
/^\/api\/agent\/[^/]+\/restore\/?$/,
|
||||
/^\/api\/files\/images\/[^/]+\/?$/,
|
||||
/^\/api\/files\/backups\/?$/,
|
||||
/^\/api\/tus\/hooks\/?$/,
|
||||
/^\/api\/events\/?$/,
|
||||
/^\/api\/config\/?$/,
|
||||
/^\/api\/google\/drive\/callback\/?$/,
|
||||
];
|
||||
return routePatterns.some((pattern) => pattern.test(pathname));
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
"/api/:path*",
|
||||
"/dashboard/:path*",
|
||||
"/dashboard",
|
||||
]
|
||||
matcher: ["/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 1.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 {
|
||||
email: string;
|
||||
password: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export const EmailCreateUser = ({email, password}: EmailCreateUserProps) => {
|
||||
@@ -23,9 +23,15 @@ export const EmailCreateUser = ({email, password}: EmailCreateUserProps) => {
|
||||
<strong>Email: </strong>{email}
|
||||
</Text>
|
||||
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
<strong>Default password: </strong>{password}
|
||||
</Text>
|
||||
{password ? (
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
<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">
|
||||
<Button
|
||||
|
||||
@@ -208,12 +208,14 @@ type UseZodFormProps<Z extends ZodSchema> = Exclude<
|
||||
schema: Z;
|
||||
};
|
||||
|
||||
|
||||
const useZodForm = <Z extends ZodSchema>({
|
||||
schema,
|
||||
...formProps
|
||||
}: UseZodFormProps<Z>) =>
|
||||
useForm({
|
||||
...formProps,
|
||||
// @ts-ignore
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,121 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import {useEffect, useState} from "react";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {toast} from "sonner";
|
||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Form} from "@/components/ui/form";
|
||||
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 { useMutation } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
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 = {
|
||||
defaultValues?: LoginType;
|
||||
defaultValues?: LoginType;
|
||||
isPasskeyEnabled?: boolean;
|
||||
};
|
||||
|
||||
export const LoginForm = (props: loginFormProps) => {
|
||||
const { isPasskeyEnabled = false } = props;
|
||||
|
||||
const url = useSearchParams();
|
||||
|
||||
const [urlParams, setUrlParams] = useState<URLSearchParams>();
|
||||
const form = useZodForm({
|
||||
schema: LoginSchema,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
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 router = useRouter();
|
||||
|
||||
const form = useZodForm({
|
||||
schema: LoginSchema,
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const mutation = useMutation({
|
||||
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);
|
||||
},
|
||||
}
|
||||
);
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: LoginType) => {
|
||||
await signIn.email(
|
||||
{
|
||||
password: values.password,
|
||||
email: values.email,
|
||||
callbackURL: url?.get("redirect") ?? "/dashboard",
|
||||
},
|
||||
});
|
||||
{
|
||||
onSuccess: (context) => {
|
||||
if (context.data.twoFactorRedirect) {
|
||||
router.push(
|
||||
"/guard?redirect=" +
|
||||
encodeURIComponent(context.data.callbackURL || "/dashboard"),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4 mb-1"
|
||||
onSubmit={async (values) => {
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
defaultValue=""
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input autoComplete="email" autoFocus placeholder="example@portabase.io" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
defaultValue=""
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Password</FormLabel>
|
||||
<div className="text-center text-sm">
|
||||
<Link href={"/forgot-password"} className="hover:underline ml-1">
|
||||
Forgot your password ?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<FormControl>
|
||||
<PasswordInput autoComplete="current-password webauthn"
|
||||
placeholder={"Enter your password"} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<ButtonWithLoading className="mt-2 h-11" isPending={mutation.isPending}>
|
||||
Login
|
||||
</ButtonWithLoading>
|
||||
</Form>
|
||||
);
|
||||
toast.success("Login success");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.error.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4 mb-1"
|
||||
onSubmit={async (values) => {
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
placeholder="example@portabase.io"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Password</FormLabel>
|
||||
<div className="text-center text-sm">
|
||||
<Link
|
||||
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";
|
||||
|
||||
import {useRouter} from "next/navigation";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {toast} from "sonner";
|
||||
import {CardContent, CardHeader} from "@/components/ui/card";
|
||||
import {Form} from "@/components/ui/form";
|
||||
import {TooltipProvider, TooltipTrigger, Tooltip, TooltipContent} from "@/components/ui/tooltip";
|
||||
import {RegisterSchema, RegisterType} from "@/components/wrappers/auth/register/register-form/register-form.schema";
|
||||
import {signUp} from "@/lib/auth/auth-client";
|
||||
import {useZodForm} from "@/components/ui/form";
|
||||
import {CardAuth} from "@/features/layout/card-auth";
|
||||
import {FormField} from "@/components/ui/form";
|
||||
import {FormItem} from "@/components/ui/form";
|
||||
import {FormLabel} from "@/components/ui/form";
|
||||
import {FormControl} from "@/components/ui/form";
|
||||
import {FormMessage} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
RegisterSchema,
|
||||
RegisterType,
|
||||
} from "@/components/wrappers/auth/register/register-form/register-form.schema";
|
||||
import { signUp } from "@/lib/auth/auth-client";
|
||||
import { useZodForm } from "@/components/ui/form";
|
||||
import { CardAuth } from "@/features/layout/card-auth";
|
||||
import { FormField } from "@/components/ui/form";
|
||||
import { FormItem } from "@/components/ui/form";
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
import { FormControl } from "@/components/ui/form";
|
||||
import { FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import {Info} from "lucide-react";
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
export type registerFormProps = {
|
||||
defaultValues?: RegisterType;
|
||||
defaultValues?: RegisterType;
|
||||
};
|
||||
|
||||
export const RegisterForm = (props: registerFormProps) => {
|
||||
const form = useZodForm({
|
||||
schema: RegisterSchema,
|
||||
});
|
||||
|
||||
const form = useZodForm({
|
||||
schema: RegisterSchema,
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: RegisterType) => {
|
||||
// @ts-ignore
|
||||
await signUp.email(values, {
|
||||
onSuccess: () => {
|
||||
toast.success(`Account successfully created`);
|
||||
router.refresh();
|
||||
router.push(`/login`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.error.message);
|
||||
},
|
||||
});
|
||||
const router = useRouter();
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: RegisterType) => {
|
||||
await signUp.email(values, {
|
||||
onSuccess: () => {
|
||||
toast.success(`Account successfully created`);
|
||||
router.refresh();
|
||||
router.push(`/login`);
|
||||
},
|
||||
});
|
||||
onError: (error) => {
|
||||
toast.error(error.error.message);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<CardAuth>
|
||||
<CardHeader>
|
||||
<div className="grid gap-2 text-center mb-2">
|
||||
<h1 className="text-3xl font-bold">Create an account</h1>
|
||||
<p className="text-balance text-muted-foreground">Enter your informations below to register</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={async (values) => {
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
defaultValue=""
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Your name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
defaultValue=""
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="example@portabase.io" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
defaultValue=""
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex">
|
||||
Password
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="ml-3" size="15"/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Min. 8 characters, 1 uppercase (A-Z), 1 lowercase (a-z), 1
|
||||
number (0-9), 1 special character (!, @,
|
||||
etc.)
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput placeholder="Your password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmPassword"
|
||||
defaultValue=""
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password Confirmation</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput placeholder="Conform your password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" className="h-11" disabled={mutation.isPending}>
|
||||
Sign up
|
||||
</Button>
|
||||
<div className="mt-4 text-center text-sm">
|
||||
Already have an account ?{" "}
|
||||
<Link href="/login" className="underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
</Form>
|
||||
</CardContent>
|
||||
</CardAuth>
|
||||
</TooltipProvider>
|
||||
);
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<CardAuth>
|
||||
<CardHeader>
|
||||
<div className="grid gap-2 text-center mb-2">
|
||||
<h1 className="text-3xl font-bold">Create an account</h1>
|
||||
<p className="text-balance text-muted-foreground">
|
||||
Enter your informations below to register
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={async (values) => {
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Your name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="example@portabase.io" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex">
|
||||
Password
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info size="15" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Min. 8 characters, 1 uppercase (A-Z), 1 lowercase
|
||||
(a-z), 1 number (0-9), 1 special character (!, @,
|
||||
etc.)
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput placeholder="Your password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmPassword"
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password Confirmation</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
placeholder="Conform your password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-11"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
Sign up
|
||||
</Button>
|
||||
<div className="mt-4 text-center text-sm">
|
||||
Already have an account ?{" "}
|
||||
<Link href="/login" className="underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</CardAuth>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,29 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useState } from "react";
|
||||
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 { 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[] }) {
|
||||
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 handleSocialSignIn = async (providerId: string) => {
|
||||
setIsLoading(providerId);
|
||||
const handleSocialSignIn = async (provider: AuthProviderConfig) => {
|
||||
setIsLoading(provider.id);
|
||||
try {
|
||||
const { error } = await authClient.signIn.social({
|
||||
provider: providerId as "google" | "github",
|
||||
callbackURL: "/dashboard",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error("An error occurred while signing in with the provider. Please try again.");
|
||||
let result;
|
||||
if (provider.id === "passkey") {
|
||||
result = await authClient.signIn.passkey({
|
||||
fetchOptions: {
|
||||
onSuccess() {
|
||||
router.push("/dashboard");
|
||||
},
|
||||
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 {
|
||||
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...");
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -38,29 +62,24 @@ export function SocialAuthButtons({ providers }: { providers: AuthProviderConfig
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{socialProviders.map((provider) => (
|
||||
<Button key={provider.id} variant="outline" className="w-full gap-2" onClick={() => handleSocialSignIn(provider.id)} disabled={!!isLoading}>
|
||||
{isLoading === provider.id ? <Loader2 className="h-4 w-4 animate-spin" /> :
|
||||
<Icon icon={provider.icon} className="h-4 w-4"/>
|
||||
}
|
||||
<span>{PROVIDERS_TEXT[provider.id].title}</span>
|
||||
<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" />
|
||||
) : provider.icon.startsWith("/") || provider.icon.startsWith("http") ? (
|
||||
<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>
|
||||
))}
|
||||
</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"
|
||||
import {Button, ButtonVariantsProps} from "@/components/ui/button";
|
||||
import {ReactNode, useState} from "react";
|
||||
import {Loader2} from "lucide-react";
|
||||
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover";
|
||||
import {cn} from "@/lib/utils";
|
||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
|
||||
"use client";
|
||||
import { Button, ButtonVariantsProps } from "@/components/ui/button";
|
||||
import { ReactNode, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
export type ButtonWithConfirmProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
button?: {
|
||||
main: {
|
||||
className?: string;
|
||||
type?: "button" | "submit" | "reset" | undefined;
|
||||
text?: string;
|
||||
icon?: any;
|
||||
variant?: ButtonVariantsProps["variant"];
|
||||
size?: ButtonVariantsProps["size"];
|
||||
disabled?: boolean;
|
||||
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;
|
||||
};
|
||||
title: string;
|
||||
description: string;
|
||||
button?: {
|
||||
main: {
|
||||
className?: string;
|
||||
type?: "button" | "submit" | "reset" | undefined;
|
||||
text?: string;
|
||||
icon?: any;
|
||||
variant?: ButtonVariantsProps["variant"];
|
||||
size?: ButtonVariantsProps["size"];
|
||||
disabled?: boolean;
|
||||
tooltipText?: string;
|
||||
};
|
||||
children?: ReactNode;
|
||||
onConfirm?: (e: React.MouseEvent) => void;
|
||||
onCancel?: (e: React.MouseEvent) => void;
|
||||
confirmButtonText?: string;
|
||||
cancelButtonText?: string;
|
||||
isPending?: boolean;
|
||||
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;
|
||||
onConfirm?: (e: React.MouseEvent) => void;
|
||||
onCancel?: (e: React.MouseEvent) => void;
|
||||
confirmButtonText?: string;
|
||||
cancelButtonText?: string;
|
||||
isPending?: boolean;
|
||||
};
|
||||
|
||||
|
||||
export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
|
||||
const isLegacy = !!props.button;
|
||||
const isDisabled = isLegacy ? !!props.button?.main.disabled : false;
|
||||
const isLegacy = !!props.button;
|
||||
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.stopPropagation();
|
||||
if (isLegacy) {
|
||||
props.button?.confirm.onClick?.();
|
||||
} else {
|
||||
props.onConfirm?.(e);
|
||||
}
|
||||
setIsConfirming(false);
|
||||
};
|
||||
|
||||
const handleCancel = (e: React.MouseEvent) => {
|
||||
if (!isDisabled) setIsConfirming(true);
|
||||
}}
|
||||
>
|
||||
{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) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isLegacy) {
|
||||
props.button?.cancel.onClick?.();
|
||||
} else {
|
||||
props.onCancel?.(e);
|
||||
}
|
||||
setIsConfirming(false);
|
||||
};
|
||||
setIsConfirming(true);
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
|
||||
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.stopPropagation();
|
||||
if (!isDisabled) setIsConfirming(true);
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
const withTooltip =
|
||||
isLegacy && props.button?.main.tooltipText && isDisabled ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>{triggerContent}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{props.button?.main.tooltipText}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<div onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsConfirming(true);
|
||||
}}>
|
||||
{props.children}
|
||||
</div>
|
||||
triggerContent
|
||||
);
|
||||
|
||||
const withTooltip = isLegacy && props.button?.main.tooltipText && isDisabled ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>{triggerContent}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{props.button?.main.tooltipText}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : triggerContent;
|
||||
|
||||
return (
|
||||
<Popover open={isConfirming} onOpenChange={setIsConfirming}>
|
||||
<PopoverTrigger asChild>
|
||||
{withTooltip}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-80"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseMove={(e) => e.stopPropagation()}
|
||||
onMouseEnter={(e) => e.stopPropagation()}
|
||||
onMouseLeave={(e) => e.stopPropagation()}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
return (
|
||||
<Popover open={isConfirming} onOpenChange={setIsConfirming}>
|
||||
<PopoverTrigger asChild>{withTooltip}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-80"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseMove={(e) => e.stopPropagation()}
|
||||
onMouseEnter={(e) => e.stopPropagation()}
|
||||
onMouseLeave={(e) => e.stopPropagation()}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium leading-none">{props.title}</h4>
|
||||
<p className="text-sm text-muted-foreground">{props.description}</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
variant={
|
||||
isLegacy
|
||||
? (props.button?.confirm.variant ?? "default")
|
||||
: "default"
|
||||
}
|
||||
size={
|
||||
isLegacy ? (props.button?.confirm.size ?? "default") : "default"
|
||||
}
|
||||
className={cn(
|
||||
isLegacy ? props.button?.confirm.className : "",
|
||||
"w-full",
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium leading-none">{props.title}</h4>
|
||||
<p className="text-sm text-muted-foreground">{props.description}</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
variant={isLegacy ? (props.button?.confirm.variant ?? "default") : "default"}
|
||||
size={isLegacy ? (props.button?.main.size ?? "default") : "default"}
|
||||
className={cn(isLegacy ? props.button?.main.className : "", "w-full")}
|
||||
>
|
||||
{props.isPending && <Loader2 className="animate-spin mr-4" size={16}/>}
|
||||
{isLegacy && props.button?.confirm.icon}
|
||||
<span>{isLegacy ? props.button?.confirm.text : (props.confirmButtonText ?? "Confirm")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={isLegacy ? (props.button?.cancel.variant ?? "outline") : "outline"}
|
||||
onClick={handleCancel}
|
||||
className={cn(isLegacy ? props.button?.main.className : "", "w-full")}
|
||||
size={isLegacy ? (props.button?.main.size ?? "default") : "default"}
|
||||
>
|
||||
{isLegacy ? (props.button?.cancel.text ?? "Cancel") : (props.cancelButtonText ?? "Cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
{props.isPending && (
|
||||
<Loader2 className="animate-spin mr-4" size={16} />
|
||||
)}
|
||||
{isLegacy && props.button?.confirm.icon}
|
||||
<span>
|
||||
{isLegacy
|
||||
? props.button?.confirm.text
|
||||
: (props.confirmButtonText ?? "Confirm")}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
isLegacy
|
||||
? (props.button?.cancel.variant ?? "outline")
|
||||
: "outline"
|
||||
}
|
||||
onClick={handleCancel}
|
||||
className={cn(
|
||||
isLegacy ? props.button?.cancel.className : "",
|
||||
"w-full",
|
||||
)}
|
||||
size={
|
||||
isLegacy ? (props.button?.cancel.size ?? "default") : "default"
|
||||
}
|
||||
>
|
||||
{isLegacy
|
||||
? (props.button?.cancel.text ?? "Cancel")
|
||||
: (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 {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
|
||||
.string()
|
||||
.min(5, "Name must be at least 5 characters long")
|
||||
.max(40, "Name must be at most 40 characters long"),
|
||||
config: z.record(z.union([z.string(), z.number(), z.boolean(), z.null(), z.undefined()])).optional(),
|
||||
enabled: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export const NotificationChannelFormSchema = 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({
|
||||
provider: z.enum(
|
||||
["slack", "smtp", "discord", "telegram", "gotify", "ntfy", "webhook"],
|
||||
{required_error: "Provider is required"}
|
||||
),
|
||||
});
|
||||
|
||||
export const StorageChannelFormSchema = ChannelFormSchema.extend({
|
||||
provider: z.enum(["local", "s3", "google-drive"], {required_error: "Provider is required"}),
|
||||
});
|
||||
export const StorageChannelFormSchema = z.discriminatedUnion("provider", [
|
||||
BaseChannelFormSchema.extend({
|
||||
provider: z.literal("s3"),
|
||||
config: S3ChannelConfigSchema,
|
||||
}),
|
||||
BaseChannelFormSchema.extend({
|
||||
provider: z.literal("google-drive"),
|
||||
config: GoogleDriveChannelConfigSchema,
|
||||
}),
|
||||
BaseChannelFormSchema.extend({
|
||||
provider: z.literal("local"),
|
||||
config: LocalChannelConfigSchema
|
||||
})
|
||||
]);
|
||||
|
||||
|
||||
export type NotificationChannelFormType = z.infer<typeof NotificationChannelFormSchema>;
|
||||
|
||||
+2
-1
@@ -51,7 +51,8 @@ export const ChannelTestButton = ({channel, organizationId, kind}: NotifierTestC
|
||||
toast.success("Successfully connected to storage channel");
|
||||
} else {
|
||||
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 {
|
||||
toast.error("Not yet supported");
|
||||
|
||||
+35
-27
@@ -1,30 +1,38 @@
|
||||
import {UseFormReturn} from "react-hook-form";
|
||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
|
||||
type NotifierDiscordFormProps = {
|
||||
form: UseFormReturn<any, any, any>
|
||||
}
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const NotifierDiscordForm = ({form}: NotifierDiscordFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1"/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.discordWebhook"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Discord Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://discord.com/api/webhooks/..."/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export const NotifierDiscordForm = ({ form }: NotifierDiscordFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.discordWebhook"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Discord Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
placeholder="https://discord.com/api/webhooks/..."
|
||||
/>
|
||||
</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 {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
|
||||
type NotifierGotifyFormProps = {
|
||||
form: UseFormReturn<any, any, any>
|
||||
}
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const NotifierGotifyForm = ({form}: NotifierGotifyFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1"/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.gotifyServerUrl"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Gotify Server URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://gotify.example.com"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.gotifyAppToken"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Application Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="A1b2C3d4E5f6G7h"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export const NotifierGotifyForm = ({ form }: NotifierGotifyFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.gotifyServerUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Gotify Server URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://gotify.example.com" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.gotifyAppToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Application Token</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} placeholder="A1b2C3d4E5f6G7h" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</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 {Input} from "@/components/ui/input";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
|
||||
type NotifierNtfyFormProps = {
|
||||
form: UseFormReturn<any, any, any>
|
||||
@@ -48,7 +49,7 @@ export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
|
||||
<FormItem>
|
||||
<FormLabel>Access Token (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="tk_..."/>
|
||||
<PasswordInput {...field} placeholder="tk_..."/>
|
||||
</FormControl>
|
||||
<p className="text-xs text-muted-foreground">Only required for protected topics or self-hosted instances with auth.</p>
|
||||
<FormMessage/>
|
||||
@@ -79,7 +80,7 @@ export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
|
||||
<FormItem>
|
||||
<FormLabel>Basic Auth Password (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="password" placeholder="password"/>
|
||||
<PasswordInput placeholder="password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</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 {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
|
||||
type NotifierSmtpFormProps = {
|
||||
form: UseFormReturn<any, any, any>
|
||||
}
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const NotifierSlackForm = ({form}: NotifierSmtpFormProps) => {
|
||||
return(
|
||||
<>
|
||||
<Separator className="my-1"/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.slackWebhook"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Slack Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://hooks.slack.com/services/..."/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export const NotifierSlackForm = ({ form }: NotifierSmtpFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.slackWebhook"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Slack Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
placeholder="https://hooks.slack.com/services/..."
|
||||
/>
|
||||
</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 {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
|
||||
type NotifierTelegramFormProps = {
|
||||
form: UseFormReturn<any, any, any>
|
||||
}
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const NotifierTelegramForm = ({form}: NotifierTelegramFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1"/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.telegramBotToken"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Telegram Bot Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.telegramChatId"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Telegram Chat ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="-100123456789 or 123456789"/>
|
||||
</FormControl>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
You must start the conversation with the bot first (<strong>/start</strong>). <br/>
|
||||
For groups, add the bot to the group. <br/>
|
||||
You can use <a href="https://t.me/userinfobot" target="_blank" rel="noopener noreferrer" className="underline">@userinfobot</a> to find your ID.
|
||||
</p>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export const NotifierTelegramForm = ({ form }: NotifierTelegramFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.telegramBotToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Telegram Bot Token</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.telegramChatId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Telegram Chat ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="-100123456789 or 123456789" />
|
||||
</FormControl>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
You must start the conversation with the bot first (
|
||||
<strong>/start</strong>). <br />
|
||||
For groups, add the bot to the group. <br />
|
||||
You can use{" "}
|
||||
<a
|
||||
href="https://t.me/userinfobot"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
@userinfobot
|
||||
</a>{" "}
|
||||
to find your ID.
|
||||
</p>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<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 {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
|
||||
type NotifierWebhookFormProps = {
|
||||
form: UseFormReturn<any, any, any>
|
||||
}
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const NotifierWebhookForm = ({form}: NotifierWebhookFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1"/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.webhookUrl"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://example.com/api/webhook"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.webhookSecretHeader"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Header Name (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="X-Webhook-Secret"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.webhookSecret"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Secret Value (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Secret value..."/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
If provided, the secret will be sent in the specified header (defaults to <code>X-Webhook-Secret</code>).
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export const NotifierWebhookForm = ({ form }: NotifierWebhookFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.webhookUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://example.com/api/webhook" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.webhookSecretHeader"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Header Name (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="X-Webhook-Secret" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.webhookSecret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Secret Value (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} placeholder="Secret value..." />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
If provided, the secret will be sent in the specified header (defaults
|
||||
to <code>X-Webhook-Secret</code>).
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+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 {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
import {Switch} from "@/components/ui/switch";
|
||||
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
type StorageS3FormProps = {
|
||||
form: UseFormReturn<any, any, any>
|
||||
}
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const StorageS3Form = ({form}: StorageS3FormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1"/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.endPointUrl"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Endpoint URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="s3.amazonaws.com"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.region"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Region</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="us-east-1"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.accessKey"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Access Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="A1b2C3d4E5f6G7h"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.secretKey"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Secret Key</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} placeholder="A1b2C3d4E5f6G7h"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.bucketName"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bucket name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="portabase-dev"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.port"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="number" placeholder="443" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
export const StorageS3Form = ({ form }: StorageS3FormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.endPointUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Endpoint URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="s3.amazonaws.com" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.region"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Region</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="us-east-1" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.accessKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Access Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="A1b2C3d4E5f6G7h" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.secretKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Secret Key</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} placeholder="A1b2C3d4E5f6G7h" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.bucketName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bucket name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="portabase-dev" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.port"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="number" placeholder="443" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.ssl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Use SSL</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.ssl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Use SSL</FormLabel>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</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"
|
||||
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
||||
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
|
||||
import {useState} from "react";
|
||||
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
|
||||
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
||||
import {ChannelCard} from "@/components/wrappers/dashboard/admin/channels/channel/channel-card/channel-card";
|
||||
import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal";
|
||||
import {ChannelKind, getChannelTextBasedOnKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
|
||||
"use client";
|
||||
import { NotificationChannelWith } from "@/db/schema/09_notification-channel";
|
||||
import { CardsWithPagination } from "@/components/wrappers/common/cards-with-pagination";
|
||||
import { useState } from "react";
|
||||
import { EmptyStatePlaceholder } from "@/components/wrappers/common/empty-state-placeholder";
|
||||
import { OrganizationWithMembers } from "@/db/schema/03_organization";
|
||||
import { StorageChannelWith } from "@/db/schema/12_storage-channel";
|
||||
import { ChannelCard } from "@/components/wrappers/dashboard/admin/channels/channel/channel-card/channel-card";
|
||||
import { ChannelAddEditModal } from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal";
|
||||
import {
|
||||
ChannelKind,
|
||||
getChannelTextBasedOnKind,
|
||||
} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
|
||||
|
||||
type ChannelsSectionProps = {
|
||||
channels: NotificationChannelWith[] | StorageChannelWith[],
|
||||
organizations: OrganizationWithMembers[],
|
||||
kind: ChannelKind,
|
||||
defaultStorageChannelId?: string | null | undefined
|
||||
}
|
||||
channels: NotificationChannelWith[] | StorageChannelWith[];
|
||||
organizations: OrganizationWithMembers[];
|
||||
kind: ChannelKind;
|
||||
defaultStorageChannelId?: string | null | undefined;
|
||||
};
|
||||
|
||||
export const ChannelsSection = ({
|
||||
organizations,
|
||||
channels,
|
||||
kind,
|
||||
defaultStorageChannelId
|
||||
}: ChannelsSectionProps) => {
|
||||
organizations,
|
||||
channels,
|
||||
kind,
|
||||
defaultStorageChannelId,
|
||||
}: ChannelsSectionProps) => {
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const channelText = getChannelTextBasedOnKind(kind);
|
||||
const hasChannels = channels.length > 0;
|
||||
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const channelText = getChannelTextBasedOnKind(kind)
|
||||
const hasChannels = channels.length > 0
|
||||
|
||||
|
||||
return (
|
||||
return (
|
||||
<div className="h-full">
|
||||
<ChannelAddEditModal
|
||||
kind={kind}
|
||||
open={isAddModalOpen}
|
||||
onOpenChangeAction={setIsAddModalOpen}
|
||||
adminView={false}
|
||||
trigger={false}
|
||||
/>
|
||||
{hasChannels ? (
|
||||
<div className="h-full">
|
||||
<ChannelAddEditModal kind={kind} open={isAddModalOpen} onOpenChangeAction={setIsAddModalOpen}
|
||||
adminView={false}
|
||||
trigger={false}/>
|
||||
{hasChannels ? (
|
||||
<div className="h-full">
|
||||
<CardsWithPagination
|
||||
data={channels}
|
||||
cardItem={ChannelCard}
|
||||
cardsPerPage={8}
|
||||
numberOfColumns={2}
|
||||
adminView={true}
|
||||
organizations={organizations}
|
||||
kind={kind}
|
||||
defaultStorageChannelId={defaultStorageChannelId}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyStatePlaceholder
|
||||
text={`No ${channelText} channels configured yet`}
|
||||
onClick={() => {
|
||||
setIsAddModalOpen(true)
|
||||
}}
|
||||
className="h-full"
|
||||
/>
|
||||
)}
|
||||
<CardsWithPagination
|
||||
data={channels}
|
||||
cardItem={ChannelCard}
|
||||
cardsPerPage={8}
|
||||
numberOfColumns={2}
|
||||
adminView={true}
|
||||
organizations={organizations}
|
||||
kind={kind}
|
||||
defaultStorageChannelId={defaultStorageChannelId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
) : (
|
||||
<EmptyStatePlaceholder
|
||||
text={`No ${channelText} channels configured yet`}
|
||||
onClick={() => {
|
||||
setIsAddModalOpen(true);
|
||||
}}
|
||||
className="h-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+232
-53
@@ -1,62 +1,241 @@
|
||||
"use client"
|
||||
import {Eye} from "lucide-react";
|
||||
|
||||
import {useState} from "react";
|
||||
"use client";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
AlertCircle,
|
||||
Braces,
|
||||
Database,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
Hash,
|
||||
Server,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {NotificationLogWithRelations} from "@/db/services/notification-log";
|
||||
import type { NotificationLogWithRelations } from "@/db/services/notification-log";
|
||||
|
||||
type NotificationLogModalProps = {
|
||||
notificationLog: NotificationLogWithRelations;
|
||||
}
|
||||
notificationLog: NotificationLogWithRelations;
|
||||
};
|
||||
|
||||
export const NotificationLogModal = ({notificationLog}: NotificationLogModalProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const getTroubleshootingForError = (errorMsg?: any) => {
|
||||
if (!errorMsg || String(errorMsg).toLowerCase() === "null") return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" onClick={() => setOpen(true)} className="relative">
|
||||
<Eye/>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Notification details</DialogTitle>
|
||||
<DialogDescription>
|
||||
Details of the notification sent
|
||||
</DialogDescription>
|
||||
<Separator className="mt-3 mb-3"/>
|
||||
const msg = String(errorMsg).toLowerCase();
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
<div>
|
||||
<span className="font-semibold">Title:</span>{" "}
|
||||
<span>{notificationLog.content.title}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Message:</span>{" "}
|
||||
<span>{notificationLog.content.message}</span>
|
||||
</div>
|
||||
if (msg.includes("database connection") || msg.includes("agent")) {
|
||||
return {
|
||||
title: "Agent Connection Issue",
|
||||
resolution:
|
||||
"It appears the agent cannot reach the database. Please ensure that your agent is actively running, your PostgreSQL credentials are correct, and port 5432 is open on your firewall.",
|
||||
docLink:
|
||||
"https://portabase.io/docs/agent/troubleshooting/agent-connection",
|
||||
};
|
||||
}
|
||||
|
||||
if (msg.includes("timeout")) {
|
||||
return {
|
||||
title: "Timeout",
|
||||
resolution:
|
||||
"The agent is taking too long to respond. This could be due to high server load, network issues, or a large database.",
|
||||
docLink: "https://portabase.io/docs/agent/troubleshooting/timeout",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: "Unexpected Execution Error",
|
||||
resolution:
|
||||
"An unexpected error occurred during execution. Please review the raw system logs or contact support if the issue persists.",
|
||||
docLink: "https://discord.gg/Wgv7xZ8fWJ",
|
||||
};
|
||||
};
|
||||
|
||||
const getIconForKey = (key: string, value: any) => {
|
||||
const isActualError =
|
||||
key.toLowerCase().includes("error") &&
|
||||
value !== null &&
|
||||
String(value).toLowerCase() !== "null";
|
||||
|
||||
if (key.toLowerCase().includes("id"))
|
||||
return <Hash className="w-3.5 h-3.5 text-muted-foreground" />;
|
||||
if (key.toLowerCase().includes("host"))
|
||||
return <Server className="w-3.5 h-3.5 text-muted-foreground" />;
|
||||
if (isActualError)
|
||||
return <AlertCircle className="w-3.5 h-3.5 text-destructive" />;
|
||||
return <Database className="w-3.5 h-3.5 text-muted-foreground" />;
|
||||
};
|
||||
|
||||
export const NotificationLogModal = ({
|
||||
notificationLog,
|
||||
}: NotificationLogModalProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// TODO : to be fixed
|
||||
// @ts-ignore
|
||||
const payloadError = notificationLog.payload?.error;
|
||||
const troubleshooting = getTroubleshootingForError(payloadError);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setOpen(true)}
|
||||
className="relative"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-xl bg-background">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Notification Details</DialogTitle>
|
||||
<DialogDescription>Execution logs and payload data</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 mt-2 max-h-[70vh] overflow-y-auto px-1 pb-2">
|
||||
<div className="relative border rounded-xl bg-card shadow-sm flex flex-col">
|
||||
<div className="bg-primary/5 border-b px-4 py-2.5 flex items-center gap-2 rounded-t-xl">
|
||||
<div className="p-1 bg-primary/10 rounded-md">
|
||||
<Database className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<span className="font-semibold text-sm text-primary">
|
||||
Event Trigger
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-4 flex flex-col gap-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-1 sm:gap-4">
|
||||
<span className="text-xs font-medium text-muted-foreground min-w-20 pt-0.5">
|
||||
Title
|
||||
</span>
|
||||
<span className="text-sm sm:text-right flex-1 wrap-break-words">
|
||||
{notificationLog.content.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-1 sm:gap-4">
|
||||
<span className="text-xs font-medium text-muted-foreground min-w-20 pt-0.5">
|
||||
Message
|
||||
</span>
|
||||
<span className="text-sm sm:text-right flex-1 wrap-break-word text-muted-foreground">
|
||||
{notificationLog.content.message}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{notificationLog.payload && (
|
||||
<div className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-primary rounded-full border-2 border-background z-10" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-border mx-auto -my-4 relative z-0" />
|
||||
|
||||
{notificationLog.payload &&
|
||||
Object.keys(notificationLog.payload).length > 0 && (
|
||||
<div className="relative border rounded-xl bg-card shadow-sm flex flex-col">
|
||||
<div className="absolute -top-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-primary rounded-full border-2 border-background z-10" />
|
||||
|
||||
<div className="bg-primary/5 border-b px-4 py-2.5 flex items-center justify-between rounded-t-xl">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1 bg-primary/10 rounded-md">
|
||||
<Braces className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<span className="font-semibold text-sm text-primary">
|
||||
JSON Payload
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notificationLog.payload && (
|
||||
<>
|
||||
<Separator className="mb-3" />
|
||||
<div className="text-sm font-mono bg-gray-100 dark:bg-gray-800 dark:text-gray-200 p-4 rounded-md overflow-auto">
|
||||
{JSON.stringify(notificationLog.payload, null, 2)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
<div className="flex flex-col">
|
||||
{Object.entries(notificationLog.payload).map(
|
||||
([key, value], index, arr) => {
|
||||
const isActualError =
|
||||
key === "error" &&
|
||||
value !== null &&
|
||||
String(value).toLowerCase() !== "null";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`flex flex-col sm:flex-row sm:items-center justify-between gap-2 px-4 py-3 hover:bg-muted/50 transition-colors ${
|
||||
index !== arr.length - 1
|
||||
? "border-b border-border/50"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-[150px]">
|
||||
{getIconForKey(key, value)}
|
||||
<span className="text-sm font-medium">{key}</span>
|
||||
</div>
|
||||
<div className="flex-1 sm:text-right">
|
||||
{isActualError ? (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-md bg-destructive/10 text-destructive text-xs font-mono break-all">
|
||||
{String(value)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground font-mono bg-muted px-2 py-1 rounded-md break-all">
|
||||
{String(value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
|
||||
{troubleshooting && (
|
||||
<div className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-primary rounded-full border-2 border-background z-10" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{troubleshooting && (
|
||||
<>
|
||||
<div className="w-px h-6 bg-border mx-auto -my-4 relative z-0" />
|
||||
|
||||
<div className="relative border border-destructive/20 rounded-xl bg-gradient-to-b from-destructive/5 to-transparent shadow-sm flex flex-col">
|
||||
<div className="absolute -top-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-destructive rounded-full border-2 border-background z-10" />
|
||||
|
||||
<div className="bg-destructive/10 border-b border-destructive/10 px-4 py-2.5 flex items-center justify-between rounded-t-xl">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1 bg-destructive/20 rounded-md">
|
||||
<Sparkles className="w-4 h-4 text-destructive" />
|
||||
</div>
|
||||
<span className="font-semibold text-sm text-destructive">
|
||||
Suggested Resolution
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 flex flex-col gap-2">
|
||||
<h4 className="text-sm font-semibold text-foreground">
|
||||
{troubleshooting.title}
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{troubleshooting.resolution}
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<a
|
||||
href={troubleshooting.docLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-xs font-semibold text-destructive hover:text-destructive/80 transition-colors"
|
||||
>
|
||||
Read the documentation{" "}
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
+51
-4
@@ -4,7 +4,15 @@ import {Info} from "lucide-react";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {useRouter} from "next/navigation";
|
||||
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 {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
@@ -18,6 +26,7 @@ import {
|
||||
} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage.action";
|
||||
import {toast} from "sonner";
|
||||
import {Switch} from "@/components/ui/switch";
|
||||
import {downloadMasterKeyAction} from "@/features/keys/keys.action";
|
||||
|
||||
export type SettingsStorageSectionProps = {
|
||||
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 (
|
||||
<div className="flex flex-col h-full">
|
||||
<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
|
||||
configured at the database level. </AlertDescription>
|
||||
</Alert>
|
||||
<div className="flex flex-col h-full py-4 ">
|
||||
<div className="flex flex-col h-full py-4 gap-3">
|
||||
<Form
|
||||
className="space-y-4"
|
||||
form={form}
|
||||
@@ -96,8 +133,6 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="encryption"
|
||||
@@ -120,6 +155,18 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
|
||||
Confirm
|
||||
</ButtonWithLoading>
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -1,75 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {User} from "@/db/schema/02_user";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {useRouter} from "next/navigation";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { User } from "@/db/schema/02_user";
|
||||
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {authClient} from "@/lib/auth/auth-client";
|
||||
import {toast} from "sonner";
|
||||
import {setSuperAdminOwnerOfOrganizationsOwnedByUser} from "@/components/wrappers/dashboard/admin/users/user.action";
|
||||
import { authClient } from "@/lib/auth/auth-client";
|
||||
import { toast } from "sonner";
|
||||
import { setSuperAdminOwnerOfOrganizationsOwnedByUser } from "@/components/wrappers/dashboard/admin/users/user.action";
|
||||
|
||||
type AdminDeleteUserModalProps = {
|
||||
open: boolean;
|
||||
user: User;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
open: boolean;
|
||||
user: User;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const AdminDeleteUserModal = ({user, open, onOpenChange}: AdminDeleteUserModalProps) => {
|
||||
const router = useRouter();
|
||||
export const AdminDeleteUserModal = ({
|
||||
user,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: AdminDeleteUserModalProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await setSuperAdminOwnerOfOrganizationsOwnedByUser({userId: user.id});
|
||||
const result = res?.data;
|
||||
if (result?.success) {
|
||||
await authClient.admin.removeUser(
|
||||
{
|
||||
userId: user.id,
|
||||
},
|
||||
{
|
||||
onSuccess: async (response) => {
|
||||
toast.success(`User ${user.name} successfully deleted`);
|
||||
onOpenChange(false);
|
||||
router.refresh();
|
||||
},
|
||||
onError: async (error) => {
|
||||
toast.error("An error has occurred while deleting user");
|
||||
onOpenChange(false);
|
||||
},
|
||||
}
|
||||
);
|
||||
} else {
|
||||
toast.error("An error has occurred while deleting user");
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await setSuperAdminOwnerOfOrganizationsOwnedByUser({
|
||||
userId: user.id,
|
||||
});
|
||||
const result = res?.data;
|
||||
if (result?.success) {
|
||||
await authClient.admin.removeUser(
|
||||
{
|
||||
userId: user.id,
|
||||
},
|
||||
{
|
||||
onSuccess: async () => {
|
||||
toast.success(`User ${user.name} successfully deleted`);
|
||||
onOpenChange(false);
|
||||
router.refresh();
|
||||
},
|
||||
onError: async (error) => {
|
||||
toast.error("An error has occurred while deleting user");
|
||||
onOpenChange(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
toast.error("An error has occurred while deleting user");
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure you want to delete {user.name} ?</AlertDialogTitle>
|
||||
<AlertDialogDescription>This action is irreversible: it will lead to the deletion of the user's
|
||||
data.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<ButtonWithLoading
|
||||
onClick={async () => await mutation.mutateAsync()}>Confirm</ButtonWithLoading>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Are you sure you want to delete {user.name} ?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action is irreversible: it will lead to the deletion of the
|
||||
user's data.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<ButtonWithLoading onClick={async () => await mutation.mutateAsync()}>
|
||||
Confirm
|
||||
</ButtonWithLoading>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,8 +5,9 @@ import {User} from "@/db/schema/02_user";
|
||||
|
||||
type AdminUserListProps = {
|
||||
users: User[];
|
||||
isPasswordAuthEnabled: boolean;
|
||||
};
|
||||
|
||||
export const AdminUserList = ({ users }: AdminUserListProps) => {
|
||||
return <DataTable columns={usersListColumns()} data={users} enablePagination={true} enableSelect={false} />;
|
||||
export const AdminUserList = ({ users, isPasswordAuthEnabled }: AdminUserListProps) => {
|
||||
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 {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 [
|
||||
{
|
||||
@@ -85,7 +89,7 @@ export function usersListColumns(): ColumnDef<User>[] {
|
||||
{
|
||||
id: "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 {
|
||||
user: User;
|
||||
isPasswordAuthEnabled: boolean;
|
||||
}
|
||||
|
||||
export function UserActionsCell({user}: UserActionsCellProps) {
|
||||
export function UserActionsCell({user, isPasswordAuthEnabled}: UserActionsCellProps) {
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isModalChangePasswordOpen, setIsModalChangePasswordOpen] = useState(false);
|
||||
@@ -43,9 +44,11 @@ export function UserActionsCell({user}: UserActionsCellProps) {
|
||||
onOpenChange={setIsModalChangePasswordOpen}/>
|
||||
<AdminUserEdit user={user} open={isModalEditUserOpen} onOpenChange={setIsModalEditUserOpen}/>
|
||||
<div className={cn("flex items-center space-x-2")}>
|
||||
<Button variant="outline" size="icon" onClick={() => setIsModalChangePasswordOpen(true)}>
|
||||
<RotateCcwKey className="w-4 h-4"/>
|
||||
</Button>
|
||||
{isPasswordAuthEnabled && (
|
||||
<Button variant="outline" size="icon" onClick={() => setIsModalChangePasswordOpen(true)}>
|
||||
<RotateCcwKey className="w-4 h-4"/>
|
||||
</Button>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
|
||||
@@ -1,191 +1,211 @@
|
||||
"use server";
|
||||
|
||||
import * as drizzleDb from "@/db";
|
||||
import {ServerActionResult} from "@/types/action-type";
|
||||
import {render} from "@react-email/render";
|
||||
import {UserSchema} from "@/components/wrappers/dashboard/admin/users/user.schema";
|
||||
import {extractNameFromEmail} from "@/utils/name-from-email";
|
||||
import {generateValidPassword} from "@/utils/password";
|
||||
import {auth} from "@/lib/auth/auth";
|
||||
import {z} from "zod";
|
||||
import {Organization} from "@/db/schema/03_organization";
|
||||
import {db} from "@/db";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
import { ServerActionResult } from "@/types/action-type";
|
||||
import { render } from "@react-email/render";
|
||||
import { UserSchema } from "@/components/wrappers/dashboard/admin/users/user.schema";
|
||||
import { extractNameFromEmail } from "@/utils/name-from-email";
|
||||
import { generateValidPassword } from "@/utils/password";
|
||||
import { auth } from "@/lib/auth/auth";
|
||||
import { z } from "zod";
|
||||
import { Organization } from "@/db/schema/03_organization";
|
||||
import { db } from "@/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
import {zEmail, zString} from "@/lib/zod";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
import {
|
||||
addMemberOrganizationAction
|
||||
} from "@/components/wrappers/dashboard/admin/organizations/organization/details/add-member.action";
|
||||
import {sendEmail} from "@/lib/email";
|
||||
import { zEmail, zString } from "@/lib/zod";
|
||||
import { withUpdatedAt } from "@/db/utils";
|
||||
import { userAction } from "@/lib/safe-actions/actions";
|
||||
import { addMemberOrganizationAction } 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 {SignUpUser} from "@/types/auth";
|
||||
import {createUserDb} from "@/db/services/user";
|
||||
import {User} from "@/db/schema/02_user";
|
||||
import { SignUpUser } from "@/types/auth";
|
||||
import { createUserDb } from "@/db/services/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 {
|
||||
const password = generateValidPassword();
|
||||
const isPasswordAuthEnabled = env.AUTH_EMAIL_PASSWORD_ENABLED === "true";
|
||||
let password;
|
||||
|
||||
const userData: SignUpUser = {
|
||||
name: parsedInput.name || extractNameFromEmail(parsedInput.email),
|
||||
email: parsedInput.email,
|
||||
password: password,
|
||||
theme: "dark",
|
||||
role: "user",
|
||||
};
|
||||
const userData: SignUpUser = {
|
||||
name: parsedInput.name || extractNameFromEmail(parsedInput.email),
|
||||
email: parsedInput.email,
|
||||
theme: "dark",
|
||||
role: "user",
|
||||
};
|
||||
|
||||
const newUser = await createUserDb(userData);
|
||||
if (isPasswordAuthEnabled) {
|
||||
password = generateValidPassword();
|
||||
userData.password = password;
|
||||
}
|
||||
|
||||
if (newUser) {
|
||||
const newUser = await createUserDb(userData);
|
||||
|
||||
await sendEmail({
|
||||
to: parsedInput.email,
|
||||
subject: "Your account is created",
|
||||
html: await render(EmailCreateUser({
|
||||
password: password,
|
||||
email: parsedInput.email,
|
||||
})),
|
||||
});
|
||||
if (newUser) {
|
||||
await sendEmail({
|
||||
to: parsedInput.email,
|
||||
subject: "Your account is created",
|
||||
html: await render(
|
||||
EmailCreateUser({
|
||||
password: password,
|
||||
email: parsedInput.email,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const defaultOrganization = await db.query.organization.findFirst({
|
||||
where: eq(drizzleDb.schemas.organization.slug, "default"),
|
||||
});
|
||||
const defaultOrganization = await db.query.organization.findFirst({
|
||||
where: eq(drizzleDb.schemas.organization.slug, "default"),
|
||||
});
|
||||
|
||||
if (defaultOrganization) {
|
||||
await auth.api.addMember({
|
||||
body: {
|
||||
userId: newUser.id,
|
||||
organizationId: defaultOrganization.id,
|
||||
role: "admin",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: newUser,
|
||||
actionSuccess: {
|
||||
message: "user_created",
|
||||
},
|
||||
};
|
||||
if (defaultOrganization) {
|
||||
await auth.api.addMember({
|
||||
body: {
|
||||
userId: newUser.id,
|
||||
organizationId: defaultOrganization.id,
|
||||
role: "admin",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "user_created",
|
||||
cause: "Unknown error",
|
||||
},
|
||||
success: true,
|
||||
value: newUser,
|
||||
actionSuccess: {
|
||||
message: "user_created",
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "user_created",
|
||||
cause: "Unknown error",
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "user_created",
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "user_created",
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export const updateUserAction = userAction
|
||||
.schema(
|
||||
z.object({
|
||||
id: zString(),
|
||||
name: zString().optional(),
|
||||
email: zEmail(),
|
||||
})
|
||||
)
|
||||
.action(async ({parsedInput, ctx}): Promise<ServerActionResult<{}>> => {
|
||||
try {
|
||||
.schema(
|
||||
z.object({
|
||||
id: zString(),
|
||||
name: zString().optional(),
|
||||
email: zEmail(),
|
||||
}),
|
||||
)
|
||||
.action(async ({ parsedInput, ctx }): Promise<ServerActionResult<{}>> => {
|
||||
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({
|
||||
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",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
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
|
||||
.schema(
|
||||
z.object({
|
||||
userId: z.string(),
|
||||
})
|
||||
)
|
||||
.action(async ({parsedInput}): Promise<ServerActionResult<Organization[]>> => {
|
||||
try {
|
||||
const organizationsWhereUserIsMemberAndOwner = await db.query.member.findMany({
|
||||
where: and(eq(drizzleDb.schemas.member.role, "owner"), eq(drizzleDb.schemas.member.userId, parsedInput.userId)),
|
||||
with: {
|
||||
organization: true,
|
||||
},
|
||||
});
|
||||
.schema(
|
||||
z.object({
|
||||
userId: z.string(),
|
||||
}),
|
||||
)
|
||||
.action(
|
||||
async ({ parsedInput }): Promise<ServerActionResult<Organization[]>> => {
|
||||
try {
|
||||
const organizationsWhereUserIsMemberAndOwner =
|
||||
await db.query.member.findMany({
|
||||
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();
|
||||
if (!superAdminUser) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "set_super_admin_owner_of_organizations_owned_by_user",
|
||||
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",
|
||||
},
|
||||
};
|
||||
const superAdminUser = await db.query.user.findFirst();
|
||||
if (!superAdminUser) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "set_super_admin_owner_of_organizations_owned_by_user",
|
||||
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",
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,49 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import {useMutation, useQueryClient} from "@tanstack/react-query";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {toast} from "sonner";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {backupButtonAction} from "@/components/wrappers/dashboard/backup/backup-button/backup-button.action";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {Database, DatabaseZap} from "lucide-react";
|
||||
import {useIsMobile} from "@/hooks/use-mobile";
|
||||
import { backupButtonAction } from "@/components/wrappers/dashboard/backup/backup-button/backup-button.action";
|
||||
import { Check, DatabaseZap, X } from "lucide-react";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { ButtonWithConfirm } from "@/components/wrappers/common/button/button-with-confirm";
|
||||
|
||||
export type BackupButtonProps = {
|
||||
databaseId: string;
|
||||
disable: boolean;
|
||||
databaseId: string;
|
||||
disable: boolean;
|
||||
};
|
||||
|
||||
export const BackupButton = (props: BackupButtonProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const isMobile = useIsMobile()
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (databaseId: string) => {
|
||||
const backup = await backupButtonAction(databaseId);
|
||||
if (backup?.data?.success) {
|
||||
toast.success(backup.data.actionSuccess?.message || "Backup created successfully!");
|
||||
queryClient.invalidateQueries({queryKey: ["database-data", props.databaseId]});
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(backup?.serverError || "Failed to create backup.");
|
||||
}
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (databaseId: string) => {
|
||||
const backup = await backupButtonAction(databaseId);
|
||||
if (backup?.data?.success) {
|
||||
toast.success(
|
||||
backup.data.actionSuccess?.message || "Backup created successfully!",
|
||||
);
|
||||
queryClient.invalidateQueries({
|
||||
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 />,
|
||||
},
|
||||
});
|
||||
const HandleAction = async () => {
|
||||
await mutation.mutateAsync(props.databaseId);
|
||||
};
|
||||
|
||||
return (
|
||||
<ButtonWithLoading
|
||||
icon={<DatabaseZap/>}
|
||||
disabled={props.disable}
|
||||
isPending={mutation.isPending}
|
||||
size={"default"}
|
||||
onClick={async () => {
|
||||
await HandleAction();
|
||||
}}
|
||||
>{isMobile ? "" : "Backup"}</ButtonWithLoading>
|
||||
);
|
||||
confirm: {
|
||||
className: "w-full",
|
||||
text: "Yes, create backup",
|
||||
icon: <Check />,
|
||||
variant: "default",
|
||||
onClick: async () => {
|
||||
await HandleAction();
|
||||
},
|
||||
},
|
||||
cancel: {
|
||||
className: "w-full",
|
||||
text: "No, cancel",
|
||||
icon: <X />,
|
||||
variant: "outline",
|
||||
},
|
||||
}}
|
||||
isPending={mutation.isPending}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
import {getAccounts, getSession, getSessions} from "@/lib/auth/auth";
|
||||
import {LoggedInButtonClient} from "./logged-in-button";
|
||||
import {SUPPORTED_PROVIDERS} from "../../../../../../portabase.config";
|
||||
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
import { getAccounts, getSession, getSessions } from "@/lib/auth/auth";
|
||||
import { LoggedInButtonClient } from "./logged-in-button";
|
||||
import { SUPPORTED_PROVIDERS } from "@/lib/auth/config";
|
||||
|
||||
export const LoggedInButton = async () => {
|
||||
const user = await currentUser();
|
||||
@@ -10,7 +9,6 @@ export const LoggedInButton = async () => {
|
||||
const currentSession = await getSession();
|
||||
const accounts = await getAccounts();
|
||||
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,29 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import {ChevronsUpDown} from "lucide-react";
|
||||
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
|
||||
import {SidebarMenuButton} from "@/components/ui/sidebar";
|
||||
import {LoggedInDropdown} from "./logged-in-dropdown";
|
||||
import {Account, Session, User} from "better-auth";
|
||||
import {AuthProviderConfig} from "../../../../../../portabase.config";
|
||||
import { ChevronsUpDown } from "lucide-react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { SidebarMenuButton } from "@/components/ui/sidebar";
|
||||
import { LoggedInDropdown } from "./logged-in-dropdown";
|
||||
import { Account, Session, User } from "better-auth";
|
||||
import { AuthProviderConfig } from "@/lib/auth/config";
|
||||
|
||||
type LoggedInButtonClientProps = {
|
||||
user: User,
|
||||
sessions: Session[],
|
||||
currentSession: Session,
|
||||
accounts: Account[],
|
||||
providers: AuthProviderConfig[]
|
||||
}
|
||||
|
||||
|
||||
export const LoggedInButtonClient = ({
|
||||
user,
|
||||
sessions,
|
||||
currentSession,
|
||||
accounts,
|
||||
providers
|
||||
}: LoggedInButtonClientProps) => {
|
||||
user: User;
|
||||
sessions: Session[];
|
||||
currentSession: Session;
|
||||
accounts: Account[];
|
||||
providers: AuthProviderConfig[];
|
||||
};
|
||||
|
||||
export const LoggedInButtonClient = ({ user, sessions, currentSession, accounts, providers }: LoggedInButtonClientProps) => {
|
||||
return (
|
||||
<LoggedInDropdown
|
||||
// @ts-ignore
|
||||
@@ -40,20 +32,16 @@ export const LoggedInButtonClient = ({
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="size-6">
|
||||
<AvatarFallback>{user.name[0].toUpperCase()}</AvatarFallback>
|
||||
{user.image && <AvatarImage src={user.image}/>}
|
||||
{user.image && <AvatarImage src={user.image} />}
|
||||
</Avatar>
|
||||
<div className="flex flex-col items-start">
|
||||
<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}
|
||||
>
|
||||
{user.email}
|
||||
</span>
|
||||
<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}>
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50"/>
|
||||
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
|
||||
</SidebarMenuButton>
|
||||
</LoggedInDropdown>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {PropsWithChildren, ReactNode, useState} from "react";
|
||||
import { PropsWithChildren, ReactNode, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
@@ -10,11 +10,11 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSepara
|
||||
|
||||
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<{
|
||||
user: UserType;
|
||||
@@ -22,13 +22,10 @@ export type LoggedInDropdownProps = PropsWithChildren<{
|
||||
currentSession: Session;
|
||||
accounts: Account[];
|
||||
children: ReactNode;
|
||||
providers: AuthProviderConfig[]
|
||||
providers: AuthProviderConfig[];
|
||||
}>;
|
||||
|
||||
|
||||
|
||||
export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, children, providers }: LoggedInDropdownProps) => {
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
@@ -44,50 +41,46 @@ export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, chi
|
||||
onOpenChange={setIsModalOpen}
|
||||
providers={providers}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-[var(--radix-popper-anchor-width)] rounded-xl border-2 border-border bg-popover shadow-none p-1"
|
||||
align="start"
|
||||
side="top"
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
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"
|
||||
>
|
||||
<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" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium leading-none">Account Settings</span>
|
||||
</div>
|
||||
</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"
|
||||
onClick={async () => {
|
||||
await signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
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">
|
||||
<LogOut size={18} className="text-red-500" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium leading-none">Logout</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-[var(--radix-popper-anchor-width)] rounded-xl border-2 border-border bg-popover shadow-none p-1"
|
||||
align="start"
|
||||
side="top"
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
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"
|
||||
>
|
||||
<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" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium leading-none">Account Settings</span>
|
||||
</div>
|
||||
</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"
|
||||
onClick={async () => {
|
||||
await signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
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">
|
||||
<LogOut size={18} className="text-red-500" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium leading-none">Logout</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
|
||||
);
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
import { Account, Session, User } from "@/db/schema/02_user";
|
||||
import { ProfileSidebar } from "./profile-sidebar";
|
||||
import {ProfileProviders} from "@/components/wrappers/dashboard/profile/profile-providers";
|
||||
import {ProfileAccount} from "@/components/wrappers/dashboard/profile/profile-account";
|
||||
import {ProfileAppearance} from "@/components/wrappers/dashboard/profile/profile-apperance";
|
||||
import {ProfileSecurity} from "@/components/wrappers/dashboard/profile/profile-security";
|
||||
import {ProfileGeneral} from "@/components/wrappers/dashboard/profile/profile-general";
|
||||
import {AuthProviderConfig} from "../../../../../../portabase.config";
|
||||
import type { AuthProviderConfig } from "@/lib/auth/config";
|
||||
import { User, Session, Account } from "@/db/schema/02_user";
|
||||
import { ProfileGeneral } from "../../profile/profile-general";
|
||||
import { ProfileSecurity } from "../../profile/profile-security";
|
||||
import { ProfileProviders } from "../../profile/profile-providers";
|
||||
import { ProfileAccount } from "../../profile/profile-account";
|
||||
import { ProfileAppearance } from "../../profile/profile-apperance";
|
||||
|
||||
type ProfileModalProps = {
|
||||
open: boolean;
|
||||
@@ -19,14 +18,13 @@ type ProfileModalProps = {
|
||||
currentSession: Session;
|
||||
accounts: Account[];
|
||||
onOpenChange: (open: boolean) => void;
|
||||
providers: AuthProviderConfig[]
|
||||
|
||||
providers: AuthProviderConfig[];
|
||||
};
|
||||
|
||||
export const ProfileModal = ({ user, sessions, currentSession, accounts, open, onOpenChange, providers }: ProfileModalProps) => {
|
||||
return (
|
||||
<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">
|
||||
<DialogTitle>Settings</DialogTitle>
|
||||
<DialogDescription>Manage your account settings</DialogDescription>
|
||||
@@ -45,6 +43,9 @@ export const ProfileModal = ({ user, sessions, currentSession, accounts, open, o
|
||||
sessions={sessions}
|
||||
currentSession={currentSession}
|
||||
credentialAccount={accounts.find((acc) => acc.providerId === "credential")!}
|
||||
isPasswordEnabled={providers.some((p) => p.id === "credential")}
|
||||
isPasskeyEnabled={providers.some((p) => p.id === "passkey")}
|
||||
providers={providers}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { use } from "react";
|
||||
import React from "react";
|
||||
import { TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { UserIcon, Settings, Palette, ShieldHalf, Workflow } from "lucide-react";
|
||||
import { User } from "@/db/schema/02_user";
|
||||
@@ -10,7 +10,6 @@ interface ProfileSidebarProps {
|
||||
}
|
||||
|
||||
export function ProfileSidebar({ user }: ProfileSidebarProps) {
|
||||
|
||||
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="flex items-center px-2 mb-2">
|
||||
|
||||
+272
-231
@@ -1,253 +1,294 @@
|
||||
"use client"
|
||||
import {Backup, BackupWith, Restoration} from "@/db/schema/07_database";
|
||||
import {Swiper, SwiperSlide} from "swiper/react";
|
||||
//@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";
|
||||
"use client";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
BackupActionsSchema,
|
||||
BackupActionsType
|
||||
} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.schema";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {useMutation, useQueryClient} from "@tanstack/react-query";
|
||||
import {BackupStorageWith} from "@/db/schema/14_storage-backup";
|
||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
||||
import {getChannelIcon} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
import {getStatusColor, getStatusIcon} from "@/components/wrappers/dashboard/admin/notifications/logs/columns";
|
||||
import {useRouter} from "next/navigation";
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
useZodForm,
|
||||
} from "@/components/ui/form";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
|
||||
import { getChannelIcon } from "@/components/wrappers/dashboard/admin/channels/helpers/common";
|
||||
import {
|
||||
createRestorationBackupAction, deleteBackupAction, deleteBackupStorageAction,
|
||||
downloadBackupAction
|
||||
getStatusColor,
|
||||
getStatusIcon,
|
||||
} from "@/components/wrappers/dashboard/admin/notifications/logs/columns";
|
||||
import {
|
||||
createRestorationBackupAction,
|
||||
deleteBackupAction,
|
||||
deleteBackupStorageAction,
|
||||
downloadBackupAction,
|
||||
} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.action";
|
||||
import {toast} from "sonner";
|
||||
import {SafeActionResult} from "next-safe-action";
|
||||
import {ServerActionResult} from "@/types/action-type";
|
||||
import {ZodString} from "zod";
|
||||
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert";
|
||||
import {AlertCircleIcon} from "lucide-react";
|
||||
import {
|
||||
BackupActionsSchema,
|
||||
BackupActionsType,
|
||||
} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.schema";
|
||||
import {
|
||||
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 = {
|
||||
backup: BackupWith;
|
||||
action: DatabaseActionKind;
|
||||
}
|
||||
backup: BackupWith;
|
||||
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 {closeModal} = useBackupModal();
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const form = useZodForm({
|
||||
schema: BackupActionsSchema,
|
||||
});
|
||||
|
||||
const form = useZodForm({
|
||||
schema: BackupActionsSchema,
|
||||
});
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: BackupActionsType) => {
|
||||
let result:
|
||||
| SafeActionResult<
|
||||
string,
|
||||
ZodString,
|
||||
readonly [],
|
||||
{
|
||||
_errors?: string[] | undefined;
|
||||
},
|
||||
readonly [],
|
||||
ServerActionResult<string | Restoration | Backup>,
|
||||
object
|
||||
>
|
||||
| undefined;
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: BackupActionsType) => {
|
||||
if (action === "download") {
|
||||
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 [], {
|
||||
_errors?: string[] | undefined;
|
||||
}, readonly [], ServerActionResult<string | Restoration | Backup>, object> | undefined
|
||||
const inner = result?.data;
|
||||
|
||||
if (action === "download") {
|
||||
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,
|
||||
})
|
||||
}
|
||||
if (inner?.success) {
|
||||
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 inner = result?.data;
|
||||
const mutationDeleteEntireBackup = useMutation({
|
||||
mutationFn: async () => {
|
||||
const result = await deleteBackupAction({
|
||||
databaseId: backup.databaseId,
|
||||
backupId: backup.id,
|
||||
});
|
||||
|
||||
if (inner?.success) {
|
||||
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 inner = result?.data;
|
||||
|
||||
const mutationDeleteEntireBackup = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (inner?.success) {
|
||||
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({
|
||||
databaseId: backup.databaseId,
|
||||
backupId: backup.id,
|
||||
})
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<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) {
|
||||
toast.success(inner.actionSuccess?.message);
|
||||
queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]});
|
||||
router.refresh();
|
||||
closeModal()
|
||||
} else {
|
||||
toast.error(inner?.actionError?.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
<h3 className="font-medium text-foreground truncate flex-1">
|
||||
{storage.storageChannel?.name}
|
||||
</h3>
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
{storage.storageChannel?.provider && (
|
||||
<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
|
||||
form={form}
|
||||
className="flex flex-col gap-4 mb-1"
|
||||
onSubmit={async (values) => {
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
<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 ?
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="backupStorageId"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Choose a storage backup</FormLabel>
|
||||
<FormControl>
|
||||
|
||||
<div style={{height: "250px"}}>
|
||||
<Swiper
|
||||
direction="vertical"
|
||||
slidesPerView={3.5}
|
||||
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>
|
||||
);
|
||||
}
|
||||
{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>
|
||||
);
|
||||
};
|
||||
@@ -3,13 +3,18 @@
|
||||
import { ServerActionResult } from "@/types/action-type";
|
||||
import { z } from "zod";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth/auth";
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
import { auth, getPasskeys, revokePasskey } from "@/lib/auth/auth";
|
||||
import { userAction } from "@/lib/safe-actions/actions";
|
||||
|
||||
const RevokeSessionSchema = z.object({
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
const RevokePasskeySchema = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
|
||||
export const revokeSessionAction = userAction.schema(RevokeSessionSchema).action(async ({ parsedInput }): Promise<ServerActionResult<{}>> => {
|
||||
try {
|
||||
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";
|
||||
|
||||
import React, {useState} from "react";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
import {Loader2, AlertTriangle} from "lucide-react";
|
||||
import {Account} from "@/db/schema/02_user";
|
||||
import {authClient} from "@/lib/auth/auth-client";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {toast} from "sonner";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
|
||||
import {Alert, AlertDescription} from "@/components/ui/alert";
|
||||
import {SetPasswordProfileProviderModal} from "./modal/set-password-modal";
|
||||
import {AuthProviderConfig} from "../../../../../portabase.config";
|
||||
import {Icon} from "@iconify/react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Loader2, AlertTriangle } from "lucide-react";
|
||||
import { authClient } from "@/lib/auth/auth-client";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
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 {
|
||||
accounts: Account[];
|
||||
providers: AuthProviderConfig[]
|
||||
|
||||
accounts: Account[];
|
||||
providers: AuthProviderConfig[];
|
||||
}
|
||||
|
||||
export function ProfileProviders({accounts, providers}: ProfileProviderProps) {
|
||||
const router = useRouter();
|
||||
const totalConnected = accounts.length;
|
||||
const [loadingProvider, setLoadingProvider] = useState<string | null>(null);
|
||||
export function ProfileProviders({
|
||||
accounts,
|
||||
providers,
|
||||
}: 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({
|
||||
mutationFn: async (providerId: string) => {
|
||||
setLoadingProvider(providerId);
|
||||
const {error} = await authClient.unlinkAccount({ providerId });
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Provider successfully unlinked!");
|
||||
setLoadingProvider(null);
|
||||
router.refresh();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("An error occurred while unlinking provider.");
|
||||
setLoadingProvider(null);
|
||||
},
|
||||
});
|
||||
const { mutate: unlinkAccount } = useMutation({
|
||||
mutationFn: async (providerId: string) => {
|
||||
setLoadingProvider(providerId);
|
||||
const { error } = await authClient.unlinkAccount({ providerId });
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Provider successfully unlinked!");
|
||||
setLoadingProvider(null);
|
||||
router.refresh();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("An error occurred while unlinking provider.");
|
||||
setLoadingProvider(null);
|
||||
},
|
||||
});
|
||||
|
||||
const {mutate: linkAccount} = useMutation({
|
||||
mutationFn: async (providerId: string) => {
|
||||
setLoadingProvider(providerId);
|
||||
const {error} = await authClient.signIn.social({
|
||||
provider: providerId as "google" | "github" | "credential",
|
||||
callbackURL: "/dashboard",
|
||||
});
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Provider successfully Linked!");
|
||||
setLoadingProvider(null);
|
||||
router.refresh();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("An error occurred while linking provider.");
|
||||
setLoadingProvider(null);
|
||||
},
|
||||
});
|
||||
const { mutate: linkAccount } = useMutation({
|
||||
mutationFn: async (provider: AuthProviderConfig) => {
|
||||
setLoadingProvider(provider.id);
|
||||
if (provider.type === "social") {
|
||||
await authClient.linkSocial({
|
||||
provider: provider.id as string,
|
||||
callbackURL: "/dashboard",
|
||||
});
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
setLoadingProvider(null);
|
||||
router.refresh();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("An error occurred while linking provider.");
|
||||
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 (
|
||||
<div className="space-y-8 animate-in fade-in-50 duration-300">
|
||||
<div className="mb-6 space-y-1">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Connected Accounts</h2>
|
||||
<p className="text-sm text-muted-foreground">Manage the providers used to sign in to your account.</p>
|
||||
<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">
|
||||
{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 className="grid gap-4">
|
||||
{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 className="text-sm text-muted-foreground">
|
||||
{provider.description}
|
||||
</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";
|
||||
|
||||
import {useState} from "react";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
import {Globe, LogOut, Loader2} from "lucide-react";
|
||||
import {Account, Session, User} from "@/db/schema/02_user";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {toast} from "sonner";
|
||||
import {revokeAllSessionsAction, revokeSessionAction} 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 { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Globe,
|
||||
LogOut,
|
||||
Loader2,
|
||||
Fingerprint,
|
||||
Trash2,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
revokeAllSessionsAction,
|
||||
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 {
|
||||
user: User;
|
||||
sessions: Session[];
|
||||
credentialAccount: Account;
|
||||
currentSession: Session;
|
||||
user: User;
|
||||
sessions: Session[];
|
||||
credentialAccount: Account;
|
||||
currentSession: Session;
|
||||
isPasswordEnabled?: boolean;
|
||||
isPasskeyEnabled?: boolean;
|
||||
providers: AuthProviderConfig[];
|
||||
}
|
||||
|
||||
export function ProfileSecurity({user, sessions, credentialAccount, currentSession}: ProfileSecurityProps) {
|
||||
const router = useRouter();
|
||||
export function ProfileSecurity({
|
||||
user,
|
||||
sessions,
|
||||
credentialAccount,
|
||||
currentSession,
|
||||
isPasswordEnabled = false,
|
||||
isPasskeyEnabled = false,
|
||||
providers,
|
||||
}: ProfileSecurityProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const [isBackupCodesDialogOpen, setIsBackupCodesDialogOpen] = useState(false);
|
||||
const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false);
|
||||
const [isSetup2FADialogOpen, setIsSetup2FADialogOpen] = useState(false);
|
||||
const [isDisable2FADialogOpen, setIsDisable2FADialogOpen] = useState(false);
|
||||
const [isBackupCodesDialogOpen, setIsBackupCodesDialogOpen] = useState(false);
|
||||
const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false);
|
||||
const [isSetup2FADialogOpen, setIsSetup2FADialogOpen] = useState(false);
|
||||
const [isDisable2FADialogOpen, setIsDisable2FADialogOpen] = useState(false);
|
||||
const [isAddPasskeyOpen, setIsAddPasskeyOpen] = useState(false);
|
||||
const [passkeyName, setPasskeyName] = useState("");
|
||||
|
||||
const {mutate: revokeSession, isPending: isRevoking} = useMutation({
|
||||
mutationFn: async (token: string) => {
|
||||
const result = await revokeSessionAction({token});
|
||||
const inner = result?.data;
|
||||
if (inner?.success) {
|
||||
toast.success("Session successfully revoked");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error("An error occurred while revoking session");
|
||||
}
|
||||
},
|
||||
});
|
||||
const { mutate: revokeSession, isPending: isRevoking } = useMutation({
|
||||
mutationFn: async (token: string) => {
|
||||
const result = await revokeSessionAction({ token });
|
||||
const inner = result?.data;
|
||||
if (inner?.success) {
|
||||
toast.success("Session successfully revoked");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error("An error occurred while revoking session");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const {mutate: revokeOthers, isPending: isRevokingOthers} = useMutation({
|
||||
mutationFn: async () => {
|
||||
const result = await revokeAllSessionsAction();
|
||||
const inner = result?.data;
|
||||
if (inner?.success) {
|
||||
toast.success("Revoking all sessions successfully done.");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error("An error occurred while revoking all sessions");
|
||||
}
|
||||
},
|
||||
});
|
||||
const { mutate: revokeOthers, isPending: isRevokingOthers } = useMutation({
|
||||
mutationFn: async () => {
|
||||
const result = await revokeAllSessionsAction();
|
||||
const inner = result?.data;
|
||||
if (inner?.success) {
|
||||
toast.success("Revoking all sessions successfully done.");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error("An error occurred while revoking all sessions");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in-50 duration-300">
|
||||
<div className="mb-6 space-y-1">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Security Settings</h2>
|
||||
<p className="text-sm text-muted-foreground">Manage your password, two-factor authentication and
|
||||
sessions.</p>
|
||||
</div>
|
||||
const {
|
||||
data: passkeys,
|
||||
isLoading: isLoadingPasskeys,
|
||||
refetch: refetchPasskeys,
|
||||
} = useQuery({
|
||||
queryKey: ["passkeys"],
|
||||
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">
|
||||
<h3 className="text-lg font-medium">Authentication</h3>
|
||||
<div className="border rounded-lg p-4 space-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">Password</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{user.lastChangedPasswordAt
|
||||
? `Last changed ${timeAgo(new Date(user.lastChangedPasswordAt))}`
|
||||
: "Never changed"}
|
||||
</div>
|
||||
</div>
|
||||
{credentialAccount ? (
|
||||
<ResetPasswordProfileProviderModal open={isPasswordDialogOpen}
|
||||
onOpenChange={setIsPasswordDialogOpen}/>
|
||||
) : (
|
||||
<SetPasswordProfileProviderModal open={isPasswordDialogOpen}
|
||||
onOpenChange={setIsPasswordDialogOpen}/>
|
||||
)}
|
||||
</div>
|
||||
const { mutate: revokePasskey, isPending: isRevokingPasskey } = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const result = await revokePasskeyAction({ id });
|
||||
if (!result?.data?.success) {
|
||||
throw new Error("Failed to revoke passkey");
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Passkey revoked successfully");
|
||||
refetchPasskeys();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to revoke passkey");
|
||||
},
|
||||
});
|
||||
|
||||
<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">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-medium">Two-Factor Authentication</div>
|
||||
{user.twoFactorEnabled && (
|
||||
<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">Enhance the security of your account by
|
||||
requiring a second form of verification during login.
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in-50 duration-300">
|
||||
<div className="mb-6 space-y-1">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
Security Settings
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage your password, two-factor authentication and sessions.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{user.twoFactorEnabled ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ViewBackupCodesModal open={isBackupCodesDialogOpen}
|
||||
onOpenChange={setIsBackupCodesDialogOpen}/>
|
||||
<Disable2FAProfileProviderModal open={isDisable2FADialogOpen}
|
||||
onOpenChange={setIsDisable2FADialogOpen}/>
|
||||
</div>
|
||||
) : (
|
||||
<Setup2FAProfileProviderModal
|
||||
disabled={!credentialAccount}
|
||||
open={isSetup2FADialogOpen}
|
||||
onOpenChange={setIsSetup2FADialogOpen}
|
||||
/>
|
||||
)}
|
||||
{(isPasskeyEnabled || isPasswordEnabled || user.twoFactorEnabled) && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-medium">Authentication</h3>
|
||||
<div className="border rounded-lg p-4 space-y-4">
|
||||
{isPasswordEnabled && (
|
||||
<>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">Password</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{user.lastChangedPasswordAt
|
||||
? `Last changed ${timeAgo(new Date(user.lastChangedPasswordAt))}`
|
||||
: "Never changed"}
|
||||
</div>
|
||||
</div>
|
||||
{credentialAccount ? (
|
||||
<ResetPasswordProfileProviderModal
|
||||
open={isPasswordDialogOpen}
|
||||
onOpenChange={setIsPasswordDialogOpen}
|
||||
/>
|
||||
) : (
|
||||
<SetPasswordProfileProviderModal
|
||||
open={isPasswordDialogOpen}
|
||||
onOpenChange={setIsPasswordDialogOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<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>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{isPasswordEnabled && (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-medium">Two-Factor Authentication</div>
|
||||
{user.twoFactorEnabled && (
|
||||
<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">
|
||||
Enhance the security of your account by requiring a second
|
||||
form of verification during login.
|
||||
</div>
|
||||
</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}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="p-4 text-center text-muted-foreground">No active sessions found.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user.twoFactorEnabled ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ViewBackupCodesModal
|
||||
open={isBackupCodesDialogOpen}
|
||||
onOpenChange={setIsBackupCodesDialogOpen}
|
||||
/>
|
||||
<Disable2FAProfileProviderModal
|
||||
open={isDisable2FADialogOpen}
|
||||
onOpenChange={setIsDisable2FADialogOpen}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Setup2FAProfileProviderModal
|
||||
disabled={!credentialAccount}
|
||||
open={isSetup2FADialogOpen}
|
||||
onOpenChange={setIsSetup2FADialogOpen}
|
||||
/>
|
||||
)}
|
||||
</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({
|
||||
session,
|
||||
onRevoke,
|
||||
isRevoking,
|
||||
currentSession,
|
||||
}: {
|
||||
session: Session;
|
||||
onRevoke: (token: string) => void;
|
||||
isRevoking: boolean;
|
||||
currentSession: Session;
|
||||
session,
|
||||
onRevoke,
|
||||
isRevoking,
|
||||
currentSession,
|
||||
providers,
|
||||
}: {
|
||||
session: Session;
|
||||
onRevoke: (token: string) => void;
|
||||
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 (
|
||||
<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">
|
||||
<deviceInfo.Icon className="w-5 h-5"/>
|
||||
</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>
|
||||
{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>
|
||||
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 relative">
|
||||
<deviceInfo.Icon className="w-5 h-5" />
|
||||
{provider && (
|
||||
<div className="absolute -bottom-1 -right-1 w-5 h-5 rounded-full bg-background border flex items-center justify-center overflow-hidden">
|
||||
{provider.icon.startsWith("/") ||
|
||||
provider.icon.startsWith("http") ? (
|
||||
<Image
|
||||
src={provider.icon}
|
||||
alt={provider.id}
|
||||
width={12}
|
||||
height={12}
|
||||
className="w-3 h-3"
|
||||
unoptimized={provider.icon.startsWith("http")}
|
||||
/>
|
||||
) : (
|
||||
<Icon icon={provider.icon} className="w-3 h-3" />
|
||||
)}
|
||||
</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 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,144 +1,158 @@
|
||||
"use client"
|
||||
import {DatabaseBackupActionsModal} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions-modal";
|
||||
import {DatabaseTabs} from "@/components/wrappers/dashboard/projects/database/database-tabs";
|
||||
import {Setting} from "@/db/schema/01_setting";
|
||||
import {BackupWith, DatabaseWith, Restoration} from "@/db/schema/07_database";
|
||||
import {MemberWithUser} from "@/db/schema/03_organization";
|
||||
import {useBackupModal} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
|
||||
import {DatabaseKpi} from "@/components/wrappers/dashboard/projects/database/database-kpi";
|
||||
import {useQuery, useQueryClient} from "@tanstack/react-query";
|
||||
import {getDatabaseDataAction} from "@/components/wrappers/dashboard/database/backup/actions/get-data.action";
|
||||
import {PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
||||
import {capitalizeFirstLetter} from "@/utils/text";
|
||||
import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet";
|
||||
import {CronButton} from "@/components/wrappers/dashboard/database/cron-button/cron-button";
|
||||
import {ChannelPoliciesModal} from "@/components/wrappers/dashboard/database/channels-policy/policy-modal";
|
||||
import {HardDrive, Megaphone} from "lucide-react";
|
||||
import {ImportModal} from "@/components/wrappers/dashboard/database/import/import-modal";
|
||||
import {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
|
||||
|
||||
"use client";
|
||||
import { DatabaseBackupActionsModal } from "@/components/wrappers/dashboard/database/backup/actions/backup-actions-modal";
|
||||
import { DatabaseTabs } from "@/components/wrappers/dashboard/projects/database/database-tabs";
|
||||
import { Setting } from "@/db/schema/01_setting";
|
||||
import { BackupWith, DatabaseWith, Restoration } from "@/db/schema/07_database";
|
||||
import { MemberWithUser } from "@/db/schema/03_organization";
|
||||
import { useBackupModal } from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
|
||||
import { DatabaseKpi } from "@/components/wrappers/dashboard/projects/database/database-kpi";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getDatabaseDataAction } from "@/components/wrappers/dashboard/database/backup/actions/get-data.action";
|
||||
import {
|
||||
PageContent,
|
||||
PageDescription,
|
||||
PageTitle,
|
||||
} from "@/features/layout/page";
|
||||
import { capitalizeFirstLetter } from "@/utils/text";
|
||||
import { RetentionPolicySheet } from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet";
|
||||
import { CronButton } from "@/components/wrappers/dashboard/database/cron-button/cron-button";
|
||||
import { ChannelPoliciesModal } from "@/components/wrappers/dashboard/database/channels-policy/policy-modal";
|
||||
import { HardDrive, Megaphone } from "lucide-react";
|
||||
import { ImportModal } from "@/components/wrappers/dashboard/database/import/import-modal";
|
||||
import { BackupButton } from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
|
||||
|
||||
export type DatabaseContentProps = {
|
||||
settings: Setting,
|
||||
backups: BackupWith[],
|
||||
restorations: Restoration[],
|
||||
isAlreadyRestore: boolean,
|
||||
database: DatabaseWith,
|
||||
activeMember: MemberWithUser,
|
||||
totalBackups: number,
|
||||
availableBackups: number,
|
||||
successRate: number | null,
|
||||
organizationId: string,
|
||||
activeOrganizationChannels: any[],
|
||||
activeOrganizationStorageChannels: any[]
|
||||
}
|
||||
|
||||
settings: Setting;
|
||||
backups: BackupWith[];
|
||||
restorations: Restoration[];
|
||||
isAlreadyRestore: boolean;
|
||||
database: DatabaseWith;
|
||||
activeMember: MemberWithUser;
|
||||
totalBackups: number;
|
||||
availableBackups: number;
|
||||
successRate: number | null;
|
||||
organizationId: string;
|
||||
activeOrganizationChannels: any[];
|
||||
activeOrganizationStorageChannels: any[];
|
||||
};
|
||||
|
||||
export const DatabaseContent = (props: DatabaseContentProps) => {
|
||||
const {} = useBackupModal();
|
||||
const {} = useBackupModal();
|
||||
|
||||
|
||||
const {data} = useQuery({
|
||||
queryKey: ["database-data", props.database.id],
|
||||
queryFn: async () => {
|
||||
const result = await getDatabaseDataAction({databaseId: props.database.id});
|
||||
return result?.data;
|
||||
},
|
||||
initialData: {
|
||||
// TODO : to be patched
|
||||
// @ts-ignore
|
||||
database: {
|
||||
...props.database,
|
||||
project: props.database.project ?? null,
|
||||
},
|
||||
backups: props.backups,
|
||||
restorations: props.restorations,
|
||||
activeOrganizationChannels: props.activeOrganizationChannels,
|
||||
activeOrganizationStorageChannels: props.activeOrganizationStorageChannels,
|
||||
stats: {
|
||||
totalBackups: props.totalBackups,
|
||||
availableBackups: props.availableBackups,
|
||||
successRate: props.successRate
|
||||
}
|
||||
},
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
refetchInterval: 1000,
|
||||
});
|
||||
|
||||
const database = data?.database ?? props.database;
|
||||
const backups = data?.backups ?? props.backups;
|
||||
const restorations = data?.restorations ?? props.restorations;
|
||||
const activeOrganizationChannels = data?.activeOrganizationChannels ?? props.activeOrganizationChannels;
|
||||
const activeOrganizationStorageChannels = data?.activeOrganizationStorageChannels ?? props.activeOrganizationStorageChannels;
|
||||
const stats = data?.stats ?? {
|
||||
const { data } = useQuery({
|
||||
queryKey: ["database-data", props.database.id],
|
||||
queryFn: async () => {
|
||||
const result = await getDatabaseDataAction({
|
||||
databaseId: props.database.id,
|
||||
});
|
||||
return result?.data;
|
||||
},
|
||||
initialData: {
|
||||
// TODO : to be patched
|
||||
// @ts-ignore
|
||||
database: {
|
||||
...props.database,
|
||||
project: props.database.project ?? null,
|
||||
},
|
||||
backups: props.backups,
|
||||
restorations: props.restorations,
|
||||
activeOrganizationChannels: props.activeOrganizationChannels,
|
||||
activeOrganizationStorageChannels:
|
||||
props.activeOrganizationStorageChannels,
|
||||
stats: {
|
||||
totalBackups: props.totalBackups,
|
||||
availableBackups: props.availableBackups,
|
||||
successRate: props.successRate
|
||||
};
|
||||
successRate: props.successRate,
|
||||
},
|
||||
},
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
refetchInterval: 1000,
|
||||
});
|
||||
|
||||
const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
|
||||
const isAlreadyBackup = backups.some((b) => b.status === "waiting" || b.status === "ongoing");
|
||||
const database = data?.database ?? props.database;
|
||||
const backups = data?.backups ?? props.backups;
|
||||
const restorations = data?.restorations ?? props.restorations;
|
||||
const activeOrganizationChannels =
|
||||
data?.activeOrganizationChannels ?? props.activeOrganizationChannels;
|
||||
const activeOrganizationStorageChannels =
|
||||
data?.activeOrganizationStorageChannels ??
|
||||
props.activeOrganizationStorageChannels;
|
||||
const stats = data?.stats ?? {
|
||||
totalBackups: props.totalBackups,
|
||||
availableBackups: props.availableBackups,
|
||||
successRate: props.successRate,
|
||||
};
|
||||
|
||||
const isMember = props.activeMember.role === "member";
|
||||
const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
|
||||
const isAlreadyBackup = backups.some(
|
||||
(b) => b.status === "waiting" || b.status === "ongoing",
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="justify-between gap-2 sm:flex">
|
||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||
<div className="min-w-full md:min-w-fit ">
|
||||
{capitalizeFirstLetter(database.name)}
|
||||
</div>
|
||||
{!isMember && (
|
||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||
<div className="flex items-center gap-2">
|
||||
<RetentionPolicySheet database={database}/>
|
||||
<CronButton database={database}/>
|
||||
<ChannelPoliciesModal
|
||||
database={database}
|
||||
kind={"notification"}
|
||||
icon={<Megaphone/>}
|
||||
channels={activeOrganizationChannels}
|
||||
organizationId={props.organizationId}
|
||||
/>
|
||||
<ChannelPoliciesModal
|
||||
database={database}
|
||||
icon={<HardDrive/>}
|
||||
kind={"storage"}
|
||||
channels={activeOrganizationStorageChannels}
|
||||
organizationId={props.organizationId}
|
||||
/>
|
||||
<ImportModal database={database}/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<BackupButton disable={isAlreadyBackup} databaseId={database.id}/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageTitle>
|
||||
const isMember = props.activeMember.role === "member";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="justify-between gap-2 sm:flex">
|
||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||
<div className="min-w-full md:min-w-fit ">
|
||||
{capitalizeFirstLetter(database.name)}
|
||||
</div>
|
||||
{!isMember && (
|
||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||
<div className="flex items-center gap-2">
|
||||
<RetentionPolicySheet database={database} />
|
||||
<CronButton database={database} />
|
||||
<ChannelPoliciesModal
|
||||
database={database}
|
||||
kind={"notification"}
|
||||
icon={<Megaphone />}
|
||||
channels={activeOrganizationChannels}
|
||||
organizationId={props.organizationId}
|
||||
/>
|
||||
<ChannelPoliciesModal
|
||||
database={database}
|
||||
icon={<HardDrive />}
|
||||
kind={"storage"}
|
||||
channels={activeOrganizationStorageChannels}
|
||||
organizationId={props.organizationId}
|
||||
/>
|
||||
<ImportModal database={database} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<BackupButton
|
||||
disable={isAlreadyBackup}
|
||||
databaseId={database.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageTitle>
|
||||
</div>
|
||||
|
||||
{database.description && (
|
||||
<PageDescription className="mt-5 sm:mt-0">{database.description}</PageDescription>
|
||||
)}
|
||||
{database.description && (
|
||||
<PageDescription className="mt-5 sm:mt-0">
|
||||
{database.description}
|
||||
</PageDescription>
|
||||
)}
|
||||
|
||||
<PageContent className="flex flex-col w-full h-full">
|
||||
<DatabaseKpi
|
||||
successRate={stats.successRate}
|
||||
database={database}
|
||||
availableBackups={stats.availableBackups}
|
||||
totalBackups={stats.totalBackups}
|
||||
/>
|
||||
<DatabaseBackupActionsModal/>
|
||||
<DatabaseTabs
|
||||
activeMember={props.activeMember}
|
||||
settings={props.settings}
|
||||
database={database}
|
||||
isAlreadyRestore={isAlreadyRestore}
|
||||
backups={backups}
|
||||
restorations={restorations}
|
||||
/>
|
||||
</PageContent>
|
||||
</>
|
||||
)
|
||||
}
|
||||
<PageContent className="flex flex-col w-full h-full">
|
||||
<DatabaseKpi
|
||||
successRate={stats.successRate}
|
||||
database={database}
|
||||
availableBackups={stats.availableBackups}
|
||||
totalBackups={stats.totalBackups}
|
||||
/>
|
||||
<DatabaseBackupActionsModal />
|
||||
<DatabaseTabs
|
||||
activeMember={props.activeMember}
|
||||
settings={props.settings}
|
||||
database={database}
|
||||
isAlreadyRestore={isAlreadyRestore}
|
||||
backups={backups}
|
||||
restorations={restorations}
|
||||
/>
|
||||
</PageContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."dbms_status" ADD VALUE 'sqlite';
|
||||
@@ -0,0 +1,28 @@
|
||||
CREATE TABLE "sso_provider" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"issuer" text NOT NULL,
|
||||
"oidc_config" json,
|
||||
"saml_config" json,
|
||||
"user_id" uuid,
|
||||
"provider_id" text NOT NULL,
|
||||
"organization_id" text,
|
||||
"domain" text NOT NULL,
|
||||
CONSTRAINT "sso_provider_provider_id_unique" UNIQUE("provider_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "passkey" DROP CONSTRAINT "passkey_userId_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "passkey" ADD COLUMN "public_key" text NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "passkey" ADD COLUMN "user_id" uuid NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "passkey" ADD COLUMN "credential_id" text NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "passkey" ADD COLUMN "device_type" text NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "passkey" ADD COLUMN "backed_up" boolean NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "passkey" ADD COLUMN "aaguid" text;--> statement-breakpoint
|
||||
ALTER TABLE "session" ADD COLUMN "provider_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "passkey" ADD CONSTRAINT "passkey_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "passkey" DROP COLUMN "publicKey";--> statement-breakpoint
|
||||
ALTER TABLE "passkey" DROP COLUMN "userId";--> statement-breakpoint
|
||||
ALTER TABLE "passkey" DROP COLUMN "credentialId";--> statement-breakpoint
|
||||
ALTER TABLE "passkey" DROP COLUMN "deviceType";--> statement-breakpoint
|
||||
ALTER TABLE "passkey" DROP COLUMN "backedUp";
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE "passkey" RENAME COLUMN "public_key" TO "publicKey";--> statement-breakpoint
|
||||
ALTER TABLE "passkey" RENAME COLUMN "credential_id" TO "credentialId";--> statement-breakpoint
|
||||
ALTER TABLE "passkey" RENAME COLUMN "device_type" TO "deviceType";--> statement-breakpoint
|
||||
ALTER TABLE "passkey" RENAME COLUMN "backed_up" TO "backedUp";
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "passkey" RENAME COLUMN "credentialId" TO "credentialID";
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user