Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bd25353da | ||
|
|
e1bee19c18 | ||
|
|
0ec49af72a | ||
|
|
6d4eb836ab | ||
|
|
0c3cfecb1d | ||
|
|
f9186e7354 | ||
|
|
bc603a08cf | ||
|
|
077aada056 | ||
|
|
4ed5f9872d | ||
|
|
2d6be20b30 | ||
|
|
be8f3bd98c | ||
|
|
a3a20bee4a | ||
|
|
09a1e75acf | ||
|
|
ce6bdd01ef | ||
|
|
a03a1d635f | ||
|
|
419b6d4891 | ||
|
|
b4840318d5 | ||
|
|
bf81f437d1 | ||
|
|
81a4d33df1 | ||
|
|
baab364b56 | ||
|
|
585ee041ec | ||
|
|
da3d4350cc | ||
|
|
df9390847f | ||
|
|
2a1acbfde8 | ||
|
|
16ef8507b0 | ||
|
|
df5d4e336c | ||
|
|
ad2015329b | ||
|
|
4cc4bed785 | ||
|
|
6c506e6907 | ||
|
|
977f9344c7 | ||
|
|
2adb2f3b68 | ||
|
|
40a6e9e69f | ||
|
|
e1d1a3fb9a | ||
|
|
75120c0a56 | ||
|
|
33f1ef3bb3 | ||
|
|
b6e742333c | ||
|
|
9f8c66fc20 | ||
|
|
3b93257890 | ||
|
|
1104e2a074 | ||
|
|
3e950652e8 | ||
|
|
98e40466a0 | ||
|
|
324d80a735 | ||
|
|
6770be63b6 | ||
|
|
89aa33ebd8 | ||
|
|
619c32f09d | ||
|
|
c471423775 | ||
|
|
c589b9b8b8 | ||
|
|
bae9ba0973 | ||
|
|
7bfc5c1d9b | ||
|
|
8bd3b0d731 | ||
|
|
06cd4de141 | ||
|
|
33eb2df023 | ||
|
|
540ecd4b60 | ||
|
|
237418f009 | ||
|
|
4447180e9e | ||
|
|
f111946592 | ||
|
|
475e404fae | ||
|
|
07ce5215d4 | ||
|
|
9f4895fd63 | ||
|
|
c18a9da0d3 | ||
|
|
8e3eb32631 | ||
|
|
f0598b3f59 | ||
|
|
011120fdf9 | ||
|
|
ff175e1edd | ||
|
|
3f56008c39 | ||
|
|
5df95bf7c1 | ||
|
|
23d52d0447 | ||
|
|
795a127998 | ||
|
|
fd73d383f9 | ||
|
|
ee285df7b5 | ||
|
|
7f7f8e642f | ||
|
|
d9997ff5e9 | ||
|
|
36adcb323a | ||
|
|
5302060f44 | ||
|
|
d19a46bee6 | ||
|
|
d504866aad | ||
|
|
601dae4909 | ||
|
|
08f2ad0521 | ||
|
|
3b356e33e7 | ||
|
|
57d9b8da1e |
@@ -34,4 +34,4 @@ S3_USE_SSL=true
|
||||
STORAGE_TYPE=local
|
||||
|
||||
# Retention
|
||||
RETENTION_CRON="* * * * *"
|
||||
RETENTION_CRON="* * * * *"
|
||||
|
||||
@@ -26,7 +26,7 @@ Please take a moment to review this guide. It will help you understand how to co
|
||||
|
||||
2. **Clone the repository**
|
||||
```bash
|
||||
git clone https://github.com/Soluce-Technologies/portabase
|
||||
git clone https://github.com/Portabase/portabase
|
||||
```
|
||||
|
||||
3. **Set up the development environment**
|
||||
@@ -7,9 +7,9 @@
|
||||
|
||||
We take security seriously and aim to support the following versions of the project with security updates:
|
||||
|
||||
| Version | Supported |
|
||||
|-----------|--------------------|
|
||||
| 1.x | ✅ Fully Supported |
|
||||
| Version | Supported |
|
||||
|---------|--------------------|
|
||||
| Latest | ✅ Fully Supported |
|
||||
|
||||
---
|
||||
|
||||
|
After Width: | Height: | Size: 35 KiB |
@@ -0,0 +1,90 @@
|
||||
|
||||
name: Docker Publish
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
image_name:
|
||||
required: false
|
||||
type: string
|
||||
default: 'solucetechnologies/portabase'
|
||||
image_name2:
|
||||
required: false
|
||||
type: string
|
||||
default: 'portabase/portabase'
|
||||
add_latest:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
target:
|
||||
required: false
|
||||
type: string
|
||||
default: 'prod'
|
||||
dockerfile:
|
||||
required: false
|
||||
type: string
|
||||
default: './docker/dockerfile/Dockerfile'
|
||||
secrets:
|
||||
DOCKER_USERNAME:
|
||||
required: true
|
||||
DOCKER_PASSWORD:
|
||||
required: true
|
||||
DOCKER_USERNAME_2:
|
||||
required: true
|
||||
DOCKER_PASSWORD_2:
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set tags
|
||||
id: set-tags
|
||||
run: |
|
||||
REF_NAME=${GITHUB_REF#refs/tags/}
|
||||
TAGS="${{ inputs.image_name }}:$REF_NAME"
|
||||
TAGS2="${{ inputs.image_name2 }}:$REF_NAME"
|
||||
if [[ "${{ inputs.add_latest }}" == "true" ]]; then
|
||||
TAGS="$TAGS,${{ inputs.image_name }}:latest"
|
||||
fi
|
||||
echo "tags=$TAGS" >> $GITHUB_OUTPUT
|
||||
echo "tags=$TAGS2" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
- name: Log in to Docker Hub account 1
|
||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build and push to solucetechnologies/portabase
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ${{ inputs.dockerfile }}
|
||||
push: true
|
||||
tags: ${{ steps.set-tags.outputs.tags1 }}
|
||||
target: ${{ inputs.target }}
|
||||
|
||||
- name: Log in to Docker Hub account 2
|
||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME_2 }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD_2 }}
|
||||
|
||||
- name: Build and push to portabase/portabase
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ${{ inputs.dockerfile }}
|
||||
push: true
|
||||
tags: ${{ steps.set-tags.outputs.tags2 }}
|
||||
target: ${{ inputs.target }}
|
||||
@@ -0,0 +1,128 @@
|
||||
name: GitHub Release
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
prerelease:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
make_latest:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
discord_title:
|
||||
required: true
|
||||
type: string
|
||||
discord_color:
|
||||
required: true
|
||||
type: number
|
||||
discord_footer:
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
DISCORD_WEBHOOK:
|
||||
required: true
|
||||
GH_TOKEN:
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Build Changelog
|
||||
id: build_changelog
|
||||
uses: mikepenz/release-changelog-builder-action@v5
|
||||
with:
|
||||
mode: "COMMIT"
|
||||
configurationJson: |
|
||||
{
|
||||
"template": "#{{CHANGELOG}}",
|
||||
"categories": [
|
||||
{
|
||||
"title": "## Feature",
|
||||
"labels": ["feat", "feature"]
|
||||
},
|
||||
{
|
||||
"title": "## Fix",
|
||||
"labels": ["fix", "bug"]
|
||||
},
|
||||
{
|
||||
"title": "## Other",
|
||||
"labels": []
|
||||
}
|
||||
],
|
||||
"label_extractor": [
|
||||
{
|
||||
"pattern": "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test){1}(\([\w\-\.]+\))?(!)?: ([\w ])+([\s\S]*)",
|
||||
"on_property": "title",
|
||||
"target": "$1"
|
||||
}
|
||||
]
|
||||
}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: false
|
||||
body: ${{ steps.build_changelog.outputs.changelog }}
|
||||
prerelease: ${{ inputs.prerelease }}
|
||||
make_latest: ${{ inputs.make_latest }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
- name: Send Discord Notification
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
run: |
|
||||
RELEASE_INFO=$(gh release view "${{ github.ref_name }}" -R ${{ github.repository }} --json name,url,body,author)
|
||||
|
||||
RELEASE_TITLE=$(echo "$RELEASE_INFO" | jq -r .name)
|
||||
if [ -z "$RELEASE_TITLE" ] || [ "$RELEASE_TITLE" = "null" ]; then RELEASE_TITLE="${{ github.ref_name }}"; fi
|
||||
|
||||
RELEASE_URL=$(echo "$RELEASE_INFO" | jq -r .url)
|
||||
RELEASE_BODY=$(echo "$RELEASE_INFO" | jq -r .body)
|
||||
|
||||
AUTHOR_NAME="Portabase"
|
||||
AUTHOR_ICON="https://github.com/Portabase.png"
|
||||
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg title "$RELEASE_TITLE" \
|
||||
--arg description "$RELEASE_BODY" \
|
||||
--arg url "$RELEASE_URL" \
|
||||
--arg author "$AUTHOR_NAME" \
|
||||
--arg icon "$AUTHOR_ICON" \
|
||||
--arg discord_title "${{ inputs.discord_title }}" \
|
||||
--arg discord_footer "${{ inputs.discord_footer }}" \
|
||||
--argjson discord_color ${{ inputs.discord_color }} \
|
||||
'{
|
||||
content: $discord_title,
|
||||
embeds: [{
|
||||
title: $title,
|
||||
url: $url,
|
||||
description: $description,
|
||||
color: $discord_color,
|
||||
author: {
|
||||
name: $author,
|
||||
icon_url: $icon
|
||||
},
|
||||
footer: {
|
||||
text: $discord_footer
|
||||
}
|
||||
}]
|
||||
}'
|
||||
)
|
||||
|
||||
curl -H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" \
|
||||
"$DISCORD_WEBHOOK"
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Publish Docker image for release candidate
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*-rc*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
docker_publish:
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
add_latest: false
|
||||
secrets:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
DOCKER_USERNAME_2: ${{ secrets.DOCKER_USERNAME_2 }}
|
||||
DOCKER_PASSWORD_2: ${{ secrets.DOCKER_PASSWORD_2 }}
|
||||
|
||||
github_release:
|
||||
needs: docker_publish
|
||||
uses: ./.github/workflows/github.yml
|
||||
with:
|
||||
prerelease: true
|
||||
make_latest: false
|
||||
discord_title: "||@release-dashboard|| New release candidate published"
|
||||
discord_color: 16776960
|
||||
discord_footer: "Portabase"
|
||||
secrets:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,50 +1,35 @@
|
||||
name: Publish Docker image for release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
push:
|
||||
tags:
|
||||
- '*.*.*'
|
||||
- '!*-*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
push_to_registry:
|
||||
name: Push Docker image to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
attestations: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
docker_publish:
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
add_latest: true
|
||||
secrets:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
DOCKER_USERNAME_2: ${{ secrets.DOCKER_USERNAME_2 }}
|
||||
DOCKER_PASSWORD_2: ${{ secrets.DOCKER_PASSWORD_2 }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
|
||||
with:
|
||||
images: solucetechnologies/portabase
|
||||
|
||||
- name: Set tags
|
||||
id: set-tags
|
||||
run: |
|
||||
echo "RELEASE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||
TAGS="solucetechnologies/portabase:${GITHUB_REF#refs/tags/}"
|
||||
if [ "${{ github.event.release.target_commitish }}" = "main" ]; then
|
||||
TAGS="$TAGS,solucetechnologies/portabase:latest"
|
||||
fi
|
||||
echo "tags=$TAGS" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/dockerfile/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.set-tags.outputs.tags }}
|
||||
target: prod
|
||||
github_release:
|
||||
needs: docker_publish
|
||||
uses: ./.github/workflows/github.yml
|
||||
with:
|
||||
prerelease: false
|
||||
make_latest: true
|
||||
discord_title: "||@release-dashboard|| New release published"
|
||||
discord_color: 5814783
|
||||
discord_footer: "Portabase"
|
||||
secrets:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -24,5 +24,8 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0 # Fetch full history for gitleaks
|
||||
- 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
|
||||
@@ -1,2 +0,0 @@
|
||||
nodeLinker: node-modules
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
cff-version: 1.2.0
|
||||
title: Portabase
|
||||
message: "If you use this software, please cite it as below."
|
||||
type: software
|
||||
authors:
|
||||
- family-names: Gauthereau
|
||||
given-names: Charles
|
||||
- family-names: Larcher
|
||||
given-names: Killian
|
||||
repository-code: https://github.com/Portabase/portabase
|
||||
url: https://portabase.io
|
||||
abstract: "Portabase is a free, open-source, self-hosted solution for database administration, providing backup and restore capabilities, scheduling, retention policies, notifications, and support for multiple storage backends. Its headless agent architecture enables connection to multiple database instances securely and efficiently."
|
||||
keywords:
|
||||
- docker
|
||||
- kubernetes
|
||||
- backups
|
||||
- postgresql
|
||||
- mysql
|
||||
- mariadb
|
||||
- devops
|
||||
- database
|
||||
- monitoring
|
||||
- s3
|
||||
- self-hosted
|
||||
- system-administration
|
||||
- web-ui
|
||||
- agent
|
||||
license: Apache-2.0
|
||||
version: 1.1.12-rc.3
|
||||
date-released: "2026-01-11"
|
||||
@@ -1,291 +1,71 @@
|
||||
<br />
|
||||
<div align="center">
|
||||
<a href="https://portabase.io">
|
||||
<img src="/public/images/logo.png" alt="Logo" width="80" height="80">
|
||||
<img src="/.github/assets/logo.png" alt="Logo" width="80" height="80">
|
||||
</a>
|
||||
|
||||
<h3 align="center">Portabase</h3>
|
||||
<p>
|
||||
Free, open-source, and self-hosted solution for automated backup and restoration of your database instances.
|
||||
|
||||
<p align="center" style="margin-top: 20px; font-style: italic;">
|
||||
<i>Portabase is a tool designed to simplify the backup and restoration of your database instances. It integrates seamlessly with <a href="https://github.com/Portabase/agent">Portabase agents</a> for managing operations securely and efficiently.</i>
|
||||
</p>
|
||||
|
||||
[](LICENSE)
|
||||
|
||||
[](LICENSE)
|
||||
[](https://hub.docker.com/r/solucetechnologies/portabase)
|
||||
[](https://github.com/RostislavDugin/postgresus)
|
||||
[](https://github.com/Portabase/portabase)
|
||||
|
||||
[](https://www.postgresql.org/)
|
||||
[](https://www.mysql.com/)
|
||||
[](https://mariadb.org/)
|
||||
[](https://github.com/RostislavDugin/postgresus)
|
||||
[](https://github.com/Portabase/portabase)
|
||||
[](https://github.com/Portabase/portabase)
|
||||
|
||||
|
||||
[![NextJS][NextJS]][NextJS-url]
|
||||
[![BetterAuth][BetterAuth]][BetterAuth-url]
|
||||
[![Drizzle][Drizzle]][Drizzle-url]
|
||||
[![ShadcnUI][ShadcnUI]][ShadcnUI-url]
|
||||
[![Docker][Docker]][Docker-url]
|
||||
|
||||
<p>
|
||||
<strong>
|
||||
<a href="https://portabase.io">Documentation</a> •
|
||||
<a href="https://www.youtube.com/watch?v=D9uFrGxLc4s">Demo</a> •
|
||||
<a href="#installation">Installation</a> •
|
||||
<a href="#contributing">Contributing</a> •
|
||||
<a href="https://github.com/Soluce-Technologies/portabase/issues/new?labels=bug&template=bug-report---.md">Report Bug</a> •
|
||||
<a href="https://github.com/Soluce-Technologies/portabase/issues/new?labels=enhancement&template=feature-request---.md">Request Feature</a>
|
||||
<a href="https://portabase.io">Website</a> •
|
||||
<a href="https://portabase.io/docs">Documentation</a> •
|
||||
<a href="https://www.youtube.com/watch?v=hvLbX5LN1UE">Demo</a> •
|
||||
<a href="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>
|
||||
|
||||
## 📚 Table of Contents
|
||||
## Installation
|
||||
|
||||
- [About The Project](#-about-the-project)
|
||||
- [Getting Started](#-getting-started)
|
||||
- [Usage](#-usage)
|
||||
- [Roadmap](#-roadmap)
|
||||
- [Contributing](#-contributing)
|
||||
- [License](#-license)
|
||||
- [Contact](#-contact)
|
||||
- [Acknowledgments](#-acknowledgments)
|
||||
You have 4 ways to install Portabase:
|
||||
|
||||
---
|
||||
- Automated CLI (recommended) - [details](https://portabase.io/docs/dashboard/setup#cli)
|
||||
- Docker Compose setup - [details](https://portabase.io/docs/dashboard/setup#docker)
|
||||
- Kubernetes with Helm (soon)
|
||||
- Development setup - [details](https://portabase.io/docs/dashboard/setup#development)
|
||||
|
||||
## ✨ About The Project
|
||||
|
||||
**Portabase** is a server dashboard tool designed to simplify the backup and restoration of your database instances. It
|
||||
integrates seamlessly with Portabase agents for managing operations securely and efficiently.
|
||||
|
||||
GitHub Repository: [Portabase](https://github.com/Soluce-Technologies/portabase)
|
||||
|
||||
### 🔧 Built With
|
||||
|
||||
- [![NextJS][NextJS]][NextJS-url] (v16 with App Router)
|
||||
- [![Drizzle][Drizzle]][Drizzle-url]
|
||||
- [![ShadcnUI][ShadcnUI]][ShadcnUI-url]
|
||||
- [![BetterAuth][BetterAuth]][BetterAuth-url]
|
||||
- [![Docker][Docker]][Docker-url]
|
||||
**Ensure Docker is installed on your machine before getting started.**
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Getting Started
|
||||
## Contributors
|
||||
|
||||
### Installation
|
||||
[](https://github.com/Portabase/portabase/graphs/contributors)
|
||||
|
||||
Ensure Docker is installed on your machine before getting started.
|
||||
|
||||
### Option 1: Docker Compose Setup
|
||||
|
||||
Create a `docker-compose.yml` file with the following configuration:
|
||||
|
||||
```yaml
|
||||
name: portabase
|
||||
|
||||
services:
|
||||
|
||||
portabase:
|
||||
image: solucetechnologies/portabase:latest
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- '8887:80'
|
||||
environment:
|
||||
- TIME_ZONE="Europe/Paris"
|
||||
volumes:
|
||||
- portabase-private:/app/private
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
container_name: portabase-app
|
||||
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
environment:
|
||||
- POSTGRES_DB=<your_database>
|
||||
- POSTGRES_USER=<database_user>
|
||||
- POSTGRES_PASSWORD=<database_password>
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -U <database_user> -d <your_database>" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
portabase-private:
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
If you use reverse proxy like
|
||||
Traefik : [Check this link](https://portabase.io/docs/portabase/advanced-topics/reverse-proxy)
|
||||
|
||||
### Option 2: Locally (Development)
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/Soluce-Technologies/portabase
|
||||
cd portabase
|
||||
```
|
||||
2. Start the development environment:
|
||||
```bash
|
||||
docker compose up
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Usage
|
||||
|
||||
Portabase provides a web dashboard to manage your database instances and backups.
|
||||
It currently supports:
|
||||
|
||||
- **PostgreSQL**
|
||||
- **MySQL**
|
||||
|
||||
### Process
|
||||
|
||||
1. **Access the dashboard** – Open `http://localhost:8887` in your browser.
|
||||
2. **Sign up** – Register the first user, who will automatically have the **Admin** role in the default workspace.
|
||||
3. **Add your first agent** – Follow [this guide](https://github.com/Soluce-Technologies/agent-portabase) for setup
|
||||
instructions.
|
||||
4. **Create organizations and projects** – Link your databases to projects to enable backups and restores.
|
||||
5. **Configure backup policies** – Define schedules (hourly, daily, weekly, or monthly) and retention rules.
|
||||
6. **Choose a storage provider** – Select where backups will be stored (local, S3, etc.).
|
||||
7. **Save and start** – Portabase validates your configuration and starts automated backups based on your defined
|
||||
policies.
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
- [ ] Add changelog
|
||||
- [ ] Enhance documentation
|
||||
- [ ] Implement testing procedures
|
||||
- [ ] Include release file
|
||||
- [ ] Improve security
|
||||
- [ ] Enhance UX/UI
|
||||
- [ ] Improve workspace management system
|
||||
- [x] Migrate to Drizzle ORM
|
||||
- [ ] Add multi-language support
|
||||
- [ ] Extend multi-database support:
|
||||
- [x] PostgreSQL
|
||||
- [ ] MongoDB
|
||||
- [x] MySQL
|
||||
- [x] MariaDB
|
||||
|
||||
Check out [open issues](https://github.com/Soluce-Technologies/portabase/issues) for more.
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome and appreciated! Here's how to get started:
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a new branch:
|
||||
```bash
|
||||
git checkout -b feature/YourFeature
|
||||
```
|
||||
3. Commit your changes:
|
||||
```bash
|
||||
git commit -m "Add YourFeature"
|
||||
```
|
||||
4. Push to the branch:
|
||||
```bash
|
||||
git push origin feature/YourFeature
|
||||
```
|
||||
5. Open a pull request
|
||||
|
||||
Give the project a ⭐ if you like it!
|
||||
|
||||
### Top Contributors
|
||||
|
||||
[](https://github.com/Soluce-Technologies/portabase/graphs/contributors)
|
||||
|
||||
---
|
||||
|
||||
## Developer Notes
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```yml
|
||||
# Environment
|
||||
NODE_ENV=production
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://devuser:changeme@db:5432/devdb?schema=public
|
||||
|
||||
# Project Info
|
||||
PROJECT_NAME="Portabase"
|
||||
PROJECT_DESCRIPTION="Portabase is a powerful database manager"
|
||||
PROJECT_URL=http://app.portabase.io
|
||||
PROJECT_SECRET=
|
||||
|
||||
# SMTP (Email)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=
|
||||
|
||||
# Google OAuth
|
||||
AUTH_GOOGLE_ID=
|
||||
AUTH_GOOGLE_SECRET=
|
||||
AUTH_GOOGLE_METHOD=
|
||||
|
||||
# S3/MinIO Configuration
|
||||
S3_ENDPOINT=http://app.s3.portabase.io
|
||||
S3_ACCESS_KEY=
|
||||
S3_SECRET_KEY=
|
||||
S3_BUCKET_NAME=portabase
|
||||
S3_PORT=9000
|
||||
S3_USE_SSL=true
|
||||
|
||||
# Storage Backend: 'local' or 's3'
|
||||
STORAGE_TYPE=local
|
||||
|
||||
# Retention
|
||||
RETENTION_CRON="* * * * *"
|
||||
```
|
||||
|
||||
To get more information about env variables, check
|
||||
that [link](https://portabase.io/docs/portabase/advanced-topics/environment)
|
||||
|
||||
### Semantic Versioning
|
||||
|
||||
Use the following format for Docker image versioning:
|
||||
|
||||
```bash
|
||||
major.minor.patch-rc.release
|
||||
# Example: 1.0.0-rc.1
|
||||
|
||||
major.minor.patch-rc.release-tag
|
||||
# Example: 1.0.0-rc.1-dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
## License
|
||||
|
||||
Distributed under the Apache License. See `LICENSE.txt` for more details.
|
||||
|
||||
---
|
||||
|
||||
## 📬 Contact
|
||||
|
||||
- Killian Larcher - killian.larcher@soluce-technologies.com
|
||||
- Charles Gauthereau - charles.gauthereau@soluce-technologies.com
|
||||
- Project Link: [Portabase GitHub](https://github.com/Soluce-Technologies/portabase)
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
Thanks to all contributors and the open-source community!
|
||||
|
||||
[Docker]: https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=fff&style=for-the-badge
|
||||
|
||||
@@ -304,5 +84,6 @@ Thanks to all contributors and the open-source community!
|
||||
[Drizzle-url]: https://orm.drizzle.team/
|
||||
|
||||
[ShadcnUI-url]: https://ui.shadcn.com/
|
||||
|
||||
[Docker-url]: https://www.docker.com/
|
||||
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import { Card, 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 {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";
|
||||
|
||||
export default async function RoutePage(props: { searchParams: Promise<{ callbackUrl: string | undefined }> }) {
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Card className="w-full">
|
||||
<CardAuth className="w-full">
|
||||
<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>
|
||||
</Card>
|
||||
</CardAuth>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { GuardForm } from "@/components/wrappers/auth/guard/guard-form";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import {CardContent, CardHeader} from "@/components/ui/card";
|
||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
||||
import {GuardForm} from "@/components/wrappers/auth/guard/guard-form";
|
||||
import {cookies} from "next/headers";
|
||||
import {redirect} from "next/navigation";
|
||||
import {CardAuth} from "@/features/layout/card-auth";
|
||||
|
||||
export default async function GuardPage() {
|
||||
|
||||
@@ -13,20 +14,20 @@ export default async function GuardPage() {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardAuth className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<div className="grid gap-2 text-center mb-2">
|
||||
<h1 className="text-3xl font-bold">Two-factor verification</h1>
|
||||
<p className="text-balance text-muted-foreground">Please enter the verification code generated by your authentication app.</p>
|
||||
<p className="text-balance text-muted-foreground">Please enter the verification code generated
|
||||
by your authentication app.</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<GuardForm />
|
||||
<GuardForm/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardAuth>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@ 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 {Card, CardContent, CardHeader} from "@/components/ui/card";
|
||||
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";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Login",
|
||||
@@ -15,7 +16,7 @@ export default async function SignInPage() {
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Card className="w-full">
|
||||
<CardAuth className="w-full">
|
||||
<CardHeader>
|
||||
<div className="grid gap-2 text-center mb-2">
|
||||
<h1 className="text-3xl font-bold">Login</h1>
|
||||
@@ -23,18 +24,16 @@ export default async function SignInPage() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<LoginForm />
|
||||
<LoginForm/>
|
||||
|
||||
{SUPPORTED_PROVIDERS.filter((p) => !p.isManual).length > 0 && (
|
||||
{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 bg-card text-sm">OR</div>
|
||||
<div className="px-2 text-center text-sm">OR</div>
|
||||
<Separator/>
|
||||
</div>
|
||||
|
||||
<SocialAuthButtons />
|
||||
<SocialAuthButtons providers={SUPPORTED_PROVIDERS}/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -45,11 +44,9 @@ export default async function SignInPage() {
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardAuth>
|
||||
</TooltipProvider>
|
||||
|
||||
|
||||
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,59 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Metadata} from "next";
|
||||
import {ResetPasswordSection} from "@/components/wrappers/auth/reset-password/reset-password-section";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { ResetPasswordForm } from "@/components/wrappers/auth/login/reset-password-form/reset-password-form";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth/auth";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@radix-ui/react-avatar";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Reset Password",
|
||||
};
|
||||
export default async function RoutePage(props: { searchParams: Promise<{ token: string | undefined }> }) {
|
||||
|
||||
const { token } = await props.searchParams;
|
||||
|
||||
if (!token) {
|
||||
return redirect(`/login?error=invalid_or_expired_token`);
|
||||
}
|
||||
|
||||
const verification = await (await auth.$context).internalAdapter.findVerificationValue(`reset-password:${token}`);
|
||||
|
||||
if (!verification || verification.expiresAt < new Date()) {
|
||||
return redirect(`/login?error=invalid_or_expired_token`);
|
||||
}
|
||||
|
||||
const user = await (await auth.$context).internalAdapter.findUserById(verification.value);
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
return (
|
||||
<div className="mx-auto grid w-full gap-6">
|
||||
<ResetPasswordSection/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<TooltipProvider>
|
||||
<Card className="w-full max-w-md shadow-lg">
|
||||
<CardHeader className="space-y-4">
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Set a new password</h1>
|
||||
<p className="text-sm text-muted-foreground text-balance">Please enter your new password below.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center space-y-2 text-center">
|
||||
<Avatar className="relative flex h-16 w-16 shrink-0 overflow-hidden rounded-full border">
|
||||
<AvatarImage src={user!.image ?? ""} alt={user!.name} className="aspect-square h-full w-full object-cover" />
|
||||
<AvatarFallback className="flex h-full w-full items-center justify-center rounded-full bg-muted text-2xl font-medium text-muted-foreground">
|
||||
{user!.name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="font-semibold text-lg">{user!.name}</div>
|
||||
<div className="text-sm text-muted-foreground">{user!.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<ResetPasswordForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {db} from "@/db";
|
||||
import {DataTable} from "@/components/wrappers/common/table/data-table";
|
||||
import {usersColumnsAdmin} from "@/components/wrappers/dashboard/admin/users/columns-users";
|
||||
import {isNull} from "drizzle-orm";
|
||||
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";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
@@ -11,7 +11,14 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
where: (fields) => isNull(fields.deletedAt),
|
||||
with: {
|
||||
accounts: true
|
||||
}
|
||||
},
|
||||
orderBy: (fields) => desc(fields.createdAt),
|
||||
|
||||
});
|
||||
const organizations = await db.query.organization.findMany({
|
||||
with: {
|
||||
members: true,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -19,14 +26,16 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
<PageHeader className="flex flex-col">
|
||||
<div className="flex justify-between">
|
||||
<PageTitle className="mb-3">Active users</PageTitle>
|
||||
<PageActions>
|
||||
<AdminUserAddModal organizations={organizations}/>
|
||||
</PageActions>
|
||||
</div>
|
||||
</PageHeader>
|
||||
<PageContent className="flex flex-col gap-5">
|
||||
<DataTable
|
||||
enableSelect={false}
|
||||
columns={usersColumnsAdmin}
|
||||
data={users}/>
|
||||
<AdminUserList users={users}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,21 +2,16 @@ import Link from "next/link";
|
||||
import {GearIcon} from "@radix-ui/react-icons";
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageActions, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
||||
import {formatDateLastContact} from "@/utils/date-formatting";
|
||||
import {buttonVariants} from "@/components/ui/button";
|
||||
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
|
||||
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
|
||||
import {DatabaseCard} from "@/components/wrappers/dashboard/projects/project-card/project-database-card";
|
||||
import {AgentCardKey} from "@/components/wrappers/dashboard/agent/agent-card-key/agent-card-key";
|
||||
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 {Server} from "lucide-react";
|
||||
import {generateEdgeKey} from "@/utils/edge_key";
|
||||
import {getServerUrl} from "@/utils/get-server-url";
|
||||
import {AgentContentPage} from "@/components/wrappers/dashboard/agent/agent-content";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{ agentId: string }>) {
|
||||
|
||||
@@ -38,58 +33,32 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
|
||||
return (
|
||||
<Page>
|
||||
<div className="justify-between gap-2 sm:flex">
|
||||
<PageTitle className="flex items-center">
|
||||
{capitalizeFirstLetter(agent.name)}
|
||||
<Link className={buttonVariants({variant: "outline"})}
|
||||
href={`/dashboard/agents/${agent.id}/edit`}>
|
||||
<GearIcon className="w-7 h-7"/>
|
||||
</Link>
|
||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||
<div className="min-w-full md:min-w-fit ">
|
||||
{capitalizeFirstLetter(agent.name)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link className={buttonVariants({variant: "outline"})}
|
||||
href={`/dashboard/agents/${agent.id}/edit`}>
|
||||
<GearIcon className="w-7 h-7"/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"}/>
|
||||
</div>
|
||||
</div>
|
||||
</PageTitle>
|
||||
<PageActions className="justify-between">
|
||||
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"}/>
|
||||
</PageActions>
|
||||
</div>
|
||||
|
||||
{agent.description && (
|
||||
<PageDescription className="mt-5 sm:mt-0">{agent.description}</PageDescription>
|
||||
)}
|
||||
<PageContent className="flex flex-col w-full h-full justify-between gap-6">
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between gap-6 ">
|
||||
<Card className="w-full sm:w-auto flex-1">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Databases</CardTitle>
|
||||
<Server className="h-4 w-4 text-muted-foreground"/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{agent.databases.length}</div>
|
||||
<p className="text-xs text-muted-foreground">Databases linked to this agent</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="w-full sm:w-auto flex-1">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Last contact</CardTitle>
|
||||
<Server className="h-4 w-4 text-muted-foreground"/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{formatDateLastContact(agent.lastContact)}</div>
|
||||
<p className="text-xs text-muted-foreground">Last contact with agent</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
<Card className="w-full sm:w-auto flex-1 ">
|
||||
<CardHeader className="font-bold text-xl">
|
||||
Edge Key
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AgentCardKey
|
||||
edgeKey={edgeKey}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<CardsWithPagination cardsPerPage={4} numberOfColumns={2} data={agent.databases}
|
||||
cardItem={DatabaseCard}/>
|
||||
<AgentContentPage
|
||||
agent={agent}
|
||||
edgeKey={edgeKey}
|
||||
/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/
|
||||
import {notFound} from "next/navigation";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {eq, not} from "drizzle-orm";
|
||||
import {desc, eq, not} from "drizzle-orm";
|
||||
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
|
||||
import {Metadata} from "next";
|
||||
|
||||
@@ -21,11 +21,10 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
where: not(eq(drizzleDb.schemas.agent.isArchived, true)),
|
||||
with: {
|
||||
databases: true
|
||||
}
|
||||
},
|
||||
orderBy: (fields) => desc(fields.lastContact),
|
||||
});
|
||||
|
||||
console.log(agents);
|
||||
|
||||
|
||||
if (!agents) {
|
||||
notFound();
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {notFound} from "next/navigation";
|
||||
// import {RestoreForm} from "@/components/wrappers/dashboard/database/restore-form";
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
|
||||
import {db} from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
|
||||
|
||||
export default async function RoutePage(
|
||||
props: PageParams<{
|
||||
databaseId: string;
|
||||
}>
|
||||
) {
|
||||
const {databaseId} = await props.params;
|
||||
|
||||
const user = await currentUser();
|
||||
if (!user) notFound();
|
||||
|
||||
const [dbToRestore] = await db.select().from(drizzleDb.schemas.database).where(eq(drizzleDb.schemas.database.id, databaseId));
|
||||
|
||||
if (!dbToRestore) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const dbsOfSameType = await db.select().from(drizzleDb.schemas.database).where(eq(drizzleDb.schemas.database.dbms!, dbToRestore.dbms!));
|
||||
const successfulBackups = await db.select().from(drizzleDb.schemas.backup).where(eq(drizzleDb.schemas.backup.status, "success"));
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle>Restore {dbToRestore.name}</PageTitle>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
{/*<RestoreForm databaseToRestore={dbToRestore} databases={dbsOfSameType} backups={successfulBackups}/>*/}
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/ret
|
||||
import {capitalizeFirstLetter} from "@/utils/text";
|
||||
import {AlertPolicyModal} from "@/components/wrappers/dashboard/database/alert-policy/alert-policy-modal";
|
||||
import {getOrganizationChannels} from "@/db/services/notification-channel";
|
||||
import {ImportModal} from "@/components/wrappers/dashboard/database/import/import-modal";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{
|
||||
projectId: string;
|
||||
@@ -59,7 +60,7 @@ export default async function RoutePage(props: PageParams<{
|
||||
orderBy: (r, {desc}) => [desc(r.createdAt)],
|
||||
});
|
||||
|
||||
const isAlreadyBackup = backups.some((b) => b.status === "waiting");
|
||||
const isAlreadyBackup = backups.some((b) => b.status === "waiting" || b.status === "ongoing");
|
||||
const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
|
||||
|
||||
const totalBackups = await db.select({count: drizzleDb.schemas.backup.id})
|
||||
@@ -107,6 +108,7 @@ export default async function RoutePage(props: PageParams<{
|
||||
<CronButton database={dbItem}/>
|
||||
<AlertPolicyModal database={dbItem} notificationChannels={activeOrganizationChannels}
|
||||
organizationId={organization.id}/>
|
||||
<ImportModal database={dbItem}/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<BackupButton disable={isAlreadyBackup} databaseId={databaseId}/>
|
||||
|
||||
@@ -15,6 +15,11 @@ import {eq} from "drizzle-orm";
|
||||
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
||||
import * as drizzleDb from "@/db";
|
||||
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 {AlertPolicyModal} from "@/components/wrappers/dashboard/database/alert-policy/alert-policy-modal";
|
||||
import {ImportModal} from "@/components/wrappers/dashboard/database/import/import-modal";
|
||||
import {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{
|
||||
projectId: string
|
||||
@@ -55,22 +60,25 @@ export default async function RoutePage(props: PageParams<{
|
||||
return (
|
||||
<Page>
|
||||
<div className="justify-between gap-2 sm:flex">
|
||||
<PageTitle className="flex items-center">
|
||||
{capitalizeFirstLetter(proj.name)}
|
||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||
<div className="min-w-full md:min-w-fit ">
|
||||
{capitalizeFirstLetter(proj.name)}
|
||||
</div>
|
||||
{!isMember && (
|
||||
<Link className={buttonVariants({variant: "outline"})}
|
||||
href={`/dashboard/projects/${proj.id}/edit`}>
|
||||
<GearIcon className="w-7 h-7"/>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link className={buttonVariants({variant: "outline"})}
|
||||
href={`/dashboard/projects/${proj.id}/edit`}>
|
||||
<GearIcon className="w-7 h-7"/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ButtonDeleteProject projectId={projectId} text={"Delete Project"}/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageTitle>
|
||||
{!isMember && (
|
||||
<PageActions className="justify-between">
|
||||
<ButtonDeleteProject projectId={projectId} text={"Delete Project"}/>
|
||||
</PageActions>
|
||||
)}
|
||||
</div>
|
||||
<PageDescription>The list of associated databases</PageDescription>
|
||||
<PageContent className="flex flex-col w-full h-full">
|
||||
{proj.databases.length > 0 ? (
|
||||
<CardsWithPagination
|
||||
@@ -78,8 +86,8 @@ export default async function RoutePage(props: PageParams<{
|
||||
organizationSlug={organization.slug}
|
||||
// @ts-ignore
|
||||
cardItem={ProjectDatabaseCard}
|
||||
cardsPerPage={4}
|
||||
numberOfColumns={1}
|
||||
cardsPerPage={6}
|
||||
numberOfColumns={3}
|
||||
extendedProps={proj}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -56,8 +56,8 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
organizationSlug={organization.slug}
|
||||
data={projects}
|
||||
cardItem={ProjectCard}
|
||||
cardsPerPage={4}
|
||||
numberOfColumns={1}
|
||||
cardsPerPage={6}
|
||||
numberOfColumns={3}
|
||||
/>
|
||||
) : isMember ? (
|
||||
<EmptyStatePlaceholder text="No project available"/>
|
||||
|
||||
@@ -11,6 +11,13 @@ 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 {capitalizeFirstLetter} from "@/utils/text";
|
||||
import Link from "next/link";
|
||||
import {buttonVariants} from "@/components/ui/button";
|
||||
import {GearIcon} from "@radix-ui/react-icons";
|
||||
import {
|
||||
ButtonDeleteProject
|
||||
} from "@/components/wrappers/dashboard/projects/button-delete-project/button-delete-project";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Settings",
|
||||
@@ -26,25 +33,29 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
||||
}
|
||||
|
||||
const notificationChannels = await getOrganizationChannels(organization.id)
|
||||
|
||||
const permissions = computeOrganizationPermissions(activeMember);
|
||||
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle className="flex items-center">
|
||||
Organization settings
|
||||
{permissions.canManageSettings && organization.slug !== "default" && (
|
||||
<EditButtonSettings/>
|
||||
)}
|
||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||
<div className="min-w-full md:min-w-fit ">
|
||||
Organization settings
|
||||
</div>
|
||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||
<div className="flex items-center gap-2">
|
||||
{permissions.canManageSettings && organization.slug !== "default" && (
|
||||
<EditButtonSettings/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{permissions.canManageDangerZone && organization.slug !== "default" && (
|
||||
<DeleteOrganizationButton organizationSlug={organization.slug}/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageTitle>
|
||||
|
||||
<PageActions>
|
||||
{permissions.canManageDangerZone && organization.slug !== "default" && (
|
||||
<DeleteOrganizationButton organizationSlug={organization.slug}/>
|
||||
)}
|
||||
</PageActions>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<OrganizationTabs
|
||||
|
||||
@@ -78,9 +78,6 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
);
|
||||
|
||||
const Placeholder = ({text}: { text: string }) => (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">{text}</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
@@ -125,31 +122,12 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Evolution of the number of backups</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sortedBackupsEvolution.length > 0 ? (
|
||||
<EvolutionLineChart data={sortedBackupsEvolution}/>
|
||||
) : (
|
||||
<Placeholder text="No backup data available"/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<EvolutionLineChart
|
||||
data={sortedBackupsEvolution}
|
||||
/>
|
||||
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Success rate of backups</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{backupsRate.length > 0 ? (
|
||||
<PercentageLineChart data={backupsRate}/>
|
||||
) : (
|
||||
<Placeholder text="No backup rate data available"/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<PercentageLineChart
|
||||
data={backupsRate}/>
|
||||
</div>
|
||||
</PageContent>
|
||||
</Page>
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import React from "react";
|
||||
import React, {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";
|
||||
|
||||
export default async function Layout({children}: { children: React.ReactNode }) {
|
||||
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">
|
||||
<AppSidebar/>
|
||||
<SidebarInset>
|
||||
<Header/>
|
||||
<main className="h-full">{children}</main>
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</>
|
||||
<SidebarProvider>
|
||||
<div className="flex flex-col lg:flex-row w-full">
|
||||
<ThemeMetaUpdater/>
|
||||
<AppSidebar/>
|
||||
<SidebarInset>
|
||||
<Header/>
|
||||
<main className="h-full">{children}</main>
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ export async function POST(
|
||||
|
||||
if (status === "success") {
|
||||
const file = formData.get("file") as File | null;
|
||||
|
||||
const extension = formData.get("extension") as string | null;
|
||||
if (!aesKeyHex || !ivHex) {
|
||||
return NextResponse.json({error: "Missing fields"}, {status: 400});
|
||||
}
|
||||
@@ -118,12 +118,13 @@ export async function POST(
|
||||
{status: 400}
|
||||
);
|
||||
}
|
||||
const fileExtension = getFileExtension(database.dbms)
|
||||
const fileSizeBytes = file.size;
|
||||
// const fileExtension = '.' + (file.name.split('.').pop()?.toLowerCase() || '');
|
||||
const fileExtension = extension ? extension : getFileExtension(database.dbms)
|
||||
const decryptedFile = await decryptedDump(file, aesKeyHex, ivHex, fileExtension);
|
||||
const uuid = uuidv4();
|
||||
const fileName = `${uuid}${fileExtension}`;
|
||||
const buffer = Buffer.from(await decryptedFile.arrayBuffer());
|
||||
|
||||
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
|
||||
if (!settings) {
|
||||
throw new Error("System settings not found.");
|
||||
@@ -149,6 +150,7 @@ export async function POST(
|
||||
.update(drizzleDb.schemas.backup)
|
||||
.set(withUpdatedAt({
|
||||
file: fileName,
|
||||
fileSize: fileSizeBytes,
|
||||
status: 'success',
|
||||
}))
|
||||
.where(eq(drizzleDb.schemas.backup.id, backup.id));
|
||||
|
||||
@@ -24,7 +24,6 @@ export async function POST(
|
||||
const agentId = (await params).agentId
|
||||
const body: BodyResultRestore = await request.json();
|
||||
|
||||
console.log(body)
|
||||
|
||||
if (!isUuidv4(body.generatedId)) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -6,7 +6,7 @@ import {Agent} from "@/db/schema/08_agent";
|
||||
import {Database} from "@/db/schema/07_database";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {db as dbClient} from "@/db";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
import {and, eq, inArray} from "drizzle-orm";
|
||||
import {EDbmsSchema} from "@/db/schema/types";
|
||||
import {ServerActionResult} from "@/types/action-type";
|
||||
import {SafeActionResult} from "next-safe-action";
|
||||
@@ -75,8 +75,15 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
.returning();
|
||||
|
||||
|
||||
const backup = await dbClient.query.backup.findFirst({
|
||||
where: and(eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.backup.status, "waiting"))
|
||||
// const backup = await dbClient.query.backup.findFirst({
|
||||
// where: and(eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.backup.status, "waiting"))
|
||||
// })
|
||||
|
||||
const activeBackup = await dbClient.query.backup.findFirst({
|
||||
where: and(
|
||||
eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id),
|
||||
inArray(drizzleDb.schemas.backup.status, ["waiting", "ongoing"])
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -85,13 +92,13 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
})
|
||||
|
||||
|
||||
if (backup) {
|
||||
if (activeBackup && activeBackup.status == "waiting") {
|
||||
backupAction = true
|
||||
|
||||
await dbClient
|
||||
.update(drizzleDb.schemas.backup)
|
||||
.set(withUpdatedAt({status: "ongoing"}))
|
||||
.where(eq(drizzleDb.schemas.backup.id, backup.id));
|
||||
.where(eq(drizzleDb.schemas.backup.id, activeBackup.id));
|
||||
}
|
||||
|
||||
if (restoration) {
|
||||
|
||||
@@ -41,7 +41,6 @@ export async function POST(
|
||||
|
||||
if (!isUuidv4(agentId)) {
|
||||
message = "agentId is not a valid uuid"
|
||||
console.log(message)
|
||||
return NextResponse.json(
|
||||
{error: "agentId is not a valid uuid"},
|
||||
{status: 500}
|
||||
@@ -54,7 +53,6 @@ export async function POST(
|
||||
|
||||
if (!agent) {
|
||||
message = "Agent not found"
|
||||
console.log(message)
|
||||
return NextResponse.json({error: message}, {status: 404})
|
||||
}
|
||||
const databasesResponse = await handleDatabases(body, agent, lastContact)
|
||||
@@ -77,7 +75,6 @@ export async function POST(
|
||||
},
|
||||
databases: databasesResponse
|
||||
}
|
||||
console.log(response)
|
||||
|
||||
return Response.json(response)
|
||||
} catch (error) {
|
||||
|
||||
@@ -19,7 +19,6 @@ export async function GET(request: Request) {
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
console.log('Stream started');
|
||||
|
||||
const handleModification = (data: any) => {
|
||||
console.log('Modification event triggered:', data);
|
||||
controller.enqueue(`event: modification\n`);
|
||||
@@ -45,15 +44,4 @@ export async function GET(request: Request) {
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// export async function POST(request: Request) {
|
||||
// console.log('POST request received');
|
||||
// const data = await request.json();
|
||||
// console.log('Data received:', data);
|
||||
//
|
||||
// // Emit the event to all connected clients
|
||||
// eventEmitter.emit('modification', data);
|
||||
//
|
||||
// return new Response('Event sent', {status: 200});
|
||||
// }
|
||||
}
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 3.9 KiB |
@@ -16,19 +16,22 @@ export const metadata: Metadata = {
|
||||
description: process.env.PROJECT_DESCRIPTION ?? undefined,
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<meta name="apple-mobile-web-app-title" content="Portabase"/>
|
||||
<meta name="apple-mobile-web-app-title" content={title}/>
|
||||
</head>
|
||||
<body className={cn(inter.className, "h-full")}>
|
||||
<ConsoleSilencer/>
|
||||
<Providers>{children}</Providers>
|
||||
<Providers>
|
||||
{children}
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { PropsWithChildren, Suspense } from "react";
|
||||
import { ThemeProvider } from "@/features/theme/theme-provider";
|
||||
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 { Toaster } from "@/components/ui/sonner";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
export type ProviderProps = PropsWithChildren<{}>;
|
||||
const queryClient = new QueryClient();
|
||||
@@ -12,9 +14,14 @@ const queryClient = new QueryClient();
|
||||
export const Providers = (props: ProviderProps) => {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
>
|
||||
<ThemeMetaUpdaterRoot/>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Toaster />
|
||||
<Toaster/>
|
||||
{props.children}
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -1,45 +1,37 @@
|
||||
FROM node:22-alpine AS base
|
||||
|
||||
ENV YARN_VERSION=4.9.1
|
||||
|
||||
RUN apk add --update --no-cache \
|
||||
tzdata \
|
||||
build-base \
|
||||
libc6-compat \
|
||||
openssl \
|
||||
make \
|
||||
tzdata
|
||||
|
||||
FROM base AS build-env
|
||||
|
||||
RUN apk add --update --no-cache \
|
||||
build-base \
|
||||
g++ \
|
||||
jpeg-dev \
|
||||
cairo-dev \
|
||||
giflib-dev \
|
||||
pango-dev \
|
||||
make \
|
||||
libtool \
|
||||
autoconf \
|
||||
automake \
|
||||
libpng
|
||||
automake
|
||||
|
||||
RUN corepack enable && corepack prepare yarn@${YARN_VERSION}
|
||||
RUN yarn set version ${YARN_VERSION}
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
|
||||
FROM base AS deps
|
||||
|
||||
|
||||
FROM build-env AS deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY .yarn ./.yarn
|
||||
COPY .yarnrc.yml ./
|
||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
|
||||
RUN pnpm i --frozen-lockfile
|
||||
|
||||
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn; \
|
||||
elif [ -f package-lock.json ]; then npm ci; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
|
||||
FROM base AS dev
|
||||
FROM build-env AS dev
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -52,8 +44,11 @@ RUN chmod +x /app/docker/entrypoints/app-dev-entrypoint.sh
|
||||
ENTRYPOINT ["sh","/app/docker/entrypoints/app-dev-entrypoint.sh"]
|
||||
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
|
||||
|
||||
|
||||
|
||||
FROM build-env AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
@@ -61,12 +56,11 @@ COPY . .
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn run build; \
|
||||
elif [ -f package-lock.json ]; then npm run build; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
RUN pnpm run build
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
FROM base AS prod
|
||||
@@ -109,5 +103,3 @@ ENV HOSTNAME="0.0.0.0"
|
||||
USER nextjs
|
||||
|
||||
ENTRYPOINT ["sh","/app/app-prod-entrypoint.sh"]
|
||||
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
set -euo pipefail
|
||||
|
||||
echo "▶ Running Drizzle codegen..."
|
||||
npx drizzle-kit generate
|
||||
pnpm drizzle-kit generate
|
||||
|
||||
echo "▶ Applying migrations..."
|
||||
npx drizzle-kit migrate
|
||||
pnpm drizzle-kit migrate
|
||||
|
||||
echo "▶ Starting Next.js dev server..."
|
||||
exec npm run dev
|
||||
exec pnpm dev
|
||||
@@ -43,8 +43,10 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: "50mb",
|
||||
bodySizeLimit: "10gb",
|
||||
},
|
||||
proxyClientMaxBodySize: '10gb',
|
||||
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "portabase",
|
||||
"version": "1.1.5",
|
||||
"version": "1.1.12-rc.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 8887",
|
||||
@@ -97,6 +97,7 @@
|
||||
"devDependencies": {
|
||||
"@iconify/react": "^6.0.0",
|
||||
"@react-email/preview-server": "4.3.2",
|
||||
"@react-email/render": "^2.0.1",
|
||||
"@tailwindcss/postcss": "^4.1.7",
|
||||
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
||||
"@types/node": "^22.15.18",
|
||||
@@ -111,6 +112,7 @@
|
||||
"eslint": "^9.39.0",
|
||||
"eslint-config-next": "^16.0.1",
|
||||
"eslint-plugin-tailwindcss": "^3.18.0",
|
||||
"framer-motion": "^12.24.7",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"tsx": "^4.19.4",
|
||||
@@ -118,5 +120,5 @@
|
||||
"typescript": "^5.8.3",
|
||||
"zenstack": "2.14.2"
|
||||
},
|
||||
"packageManager": "yarn@4.9.1"
|
||||
"packageManager": "pnpm@10.27.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
onlyBuiltDependencies:
|
||||
- zenstack
|
||||
@@ -1,9 +1,9 @@
|
||||
import {Chrome, KeyRound, LucideIcon} from "lucide-react";
|
||||
|
||||
|
||||
export interface AuthProviderConfig {
|
||||
id: "google" | "github" | "credential";
|
||||
icon: LucideIcon;
|
||||
isActive: boolean;
|
||||
icon: string;
|
||||
isManual?: boolean;
|
||||
credentials?: {
|
||||
clientId: string;
|
||||
@@ -80,26 +80,27 @@ export const PORTABASE_DEFAULT_SETTINGS = {
|
||||
export const SUPPORTED_PROVIDERS: AuthProviderConfig[] = [
|
||||
{
|
||||
id: "google",
|
||||
icon: Chrome,
|
||||
icon: "hugeicons:chrome",
|
||||
isActive: Boolean(process.env.AUTH_GOOGLE_METHOD),
|
||||
// isManual: true,
|
||||
credentials: {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID || "",
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
|
||||
clientId: process.env.AUTH_GOOGLE_ID || "",
|
||||
clientSecret: process.env.AUTH_GOOGLE_SECRET || "",
|
||||
},
|
||||
},
|
||||
/*
|
||||
{
|
||||
id: "github",
|
||||
name: "GitHub",
|
||||
icon: Github,
|
||||
description: "Connexion via GitHub",
|
||||
icon: "iconoir:github",
|
||||
isActive: Boolean(process.env.AUTH_GITHUB_METHOD),
|
||||
credentials: {
|
||||
clientId: process.env.GITHUB_CLIENT_ID || "",
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET || "",
|
||||
clientId: process.env.AUTH_GITHUB_ID || "",
|
||||
clientSecret: process.env.AUTH_GITHUB_SECRET || "",
|
||||
},
|
||||
},*/
|
||||
},
|
||||
{
|
||||
id: "credential",
|
||||
icon: KeyRound,
|
||||
isActive: true,
|
||||
icon: "proicons:key",
|
||||
isManual: true,
|
||||
credentials: {
|
||||
clientId: "",
|
||||
|
||||
|
Before Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,95 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
if [ -z "$1" ]; then
|
||||
echo "Usage: ./release <version>"
|
||||
echo "Example: ./release v1.0.0"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION=$1
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
|
||||
if [ "$CURRENT_BRANCH" = "main" ]; then
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Error: On 'main' branch, only release tags (X.Y.Z) are allowed."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
|
||||
echo "Error: On branch '$CURRENT_BRANCH', only RC tags matching X.Y.Z-rc.W are allowed (e.g., 1.0.0-rc.1)."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
CLEAN_VERSION=${VERSION#v}
|
||||
CURRENT_DATE=$(date +%Y-%m-%d)
|
||||
|
||||
echo "Preparing release $VERSION..."
|
||||
|
||||
|
||||
# package.json
|
||||
if [ -f package.json ]; then
|
||||
echo "Updating package.json..."
|
||||
if sed --version >/dev/null 2>&1; then
|
||||
sed -i "s/\"version\": \".*\"/\"version\": \"$CLEAN_VERSION\"/" package.json
|
||||
else
|
||||
sed -i '' "s/\"version\": \".*\"/\"version\": \"$CLEAN_VERSION\"/" package.json
|
||||
fi
|
||||
fi
|
||||
|
||||
# pyproject.toml
|
||||
if [ -f pyproject.toml ]; then
|
||||
echo "Updating pyproject.toml..."
|
||||
if sed --version >/dev/null 2>&1; then
|
||||
sed -i "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" pyproject.toml
|
||||
else
|
||||
sed -i '' "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" pyproject.toml
|
||||
fi
|
||||
fi
|
||||
|
||||
# Cargo.toml
|
||||
if [ -f Cargo.toml ]; then
|
||||
echo "Updating Cargo.toml..."
|
||||
if sed --version >/dev/null 2>&1; then
|
||||
sed -i "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" Cargo.toml
|
||||
else
|
||||
sed -i '' "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" Cargo.toml
|
||||
fi
|
||||
fi
|
||||
|
||||
# CITATION.cff
|
||||
if [ -f CITATION.cff ]; then
|
||||
echo "Updating CITATION.cff..."
|
||||
if sed --version >/dev/null 2>&1; then
|
||||
sed -i "s/^version: .*/version: $CLEAN_VERSION/" CITATION.cff
|
||||
sed -i "s/^date-released: .*/date-released: \"$CURRENT_DATE\"/" CITATION.cff
|
||||
else
|
||||
sed -i '' "s/^version: .*/version: $CLEAN_VERSION/" CITATION.cff
|
||||
sed -i '' "s/^date-released: .*/date-released: \"$CURRENT_DATE\"/" CITATION.cff
|
||||
fi
|
||||
fi
|
||||
|
||||
git add .
|
||||
|
||||
if ! git diff-index --quiet HEAD --; then
|
||||
echo "Committing changes..."
|
||||
git commit -m "chore(release): $VERSION"
|
||||
else
|
||||
echo "No changes to commit. Proceeding to tag..."
|
||||
fi
|
||||
|
||||
if git rev-parse "$VERSION" >/dev/null 2>&1; then
|
||||
echo "Tag $VERSION already exists. Aborting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Creating tag $VERSION..."
|
||||
git tag -a "$VERSION" -m "Release $VERSION"
|
||||
|
||||
echo "Pushing changes and tags to remote..."
|
||||
git push
|
||||
git push origin "$VERSION"
|
||||
|
||||
echo "Successfully released $VERSION!"
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as React from "react";
|
||||
import EmailLayout from "../email-layout";
|
||||
import {Heading, Text, Section, Button} from "@react-email/components";
|
||||
import {getServerUrl} from "@/utils/get-server-url";
|
||||
|
||||
interface EmailCreateUserProps {
|
||||
firstname?: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export const EmailForgotPassword = ({firstname, token}: EmailCreateUserProps) => {
|
||||
const baseUrl = getServerUrl();
|
||||
|
||||
return (
|
||||
<EmailLayout preview="Portabase - Forgot Password">
|
||||
<Heading className="mx-0 my-[30px] p-0 text-center font-normal text-[24px] text-black">
|
||||
Hello <strong>{firstname}</strong>,
|
||||
</Heading>
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
We received a request to reset the password for your account associated with this email.
|
||||
</Text>
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
If you did not request this password reset, please ignore this email. Your current password
|
||||
will remain unchanged. If this seems suspicious, please contact your administrator.
|
||||
</Text>
|
||||
<Section className="mt-[32px] mb-[32px] text-center">
|
||||
<Button
|
||||
className="rounded bg-[#000000] px-5 py-3 text-center font-semibold text-[12px] text-white no-underline"
|
||||
href={`${baseUrl}/reset-password?token=${token}`}
|
||||
>
|
||||
Reset my password
|
||||
</Button>
|
||||
</Section>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailForgotPassword;
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as React from "react";
|
||||
import EmailLayout from "../email-layout";
|
||||
import {Heading, Text} from "@react-email/components";
|
||||
|
||||
interface EmailCreateUserProps {
|
||||
firstname?: string;
|
||||
os: string;
|
||||
browser: string;
|
||||
ipAddress?: string;
|
||||
}
|
||||
|
||||
export const EmailNewLogin = ({firstname, ipAddress, os, browser}: EmailCreateUserProps) => {
|
||||
return (
|
||||
<EmailLayout preview="Portabase - New Login">
|
||||
<Heading className="mx-0 my-[30px] p-0 text-center font-normal text-[24px] text-black">
|
||||
Hello <strong>{firstname}</strong>,
|
||||
</Heading>
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
We detected a new login to your account.
|
||||
</Text>
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
<strong>Login details:</strong>
|
||||
<br/>
|
||||
Device: {os} - {browser}
|
||||
<br/>
|
||||
IP Address: {ipAddress}
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailNewLogin;
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as React from "react";
|
||||
import EmailLayout from "./email-layout";
|
||||
import {Heading, Text, Section, Button} from "@react-email/components";
|
||||
import {getServerUrl} from "@/utils/get-server-url";
|
||||
import {env} from "@/env.mjs";
|
||||
|
||||
interface EmailCreateUserProps {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export const EmailCreateUser = ({email, password}: EmailCreateUserProps) => {
|
||||
const baseUrl = getServerUrl();
|
||||
|
||||
|
||||
return (
|
||||
<EmailLayout preview="Portabase Dashboard">
|
||||
<Heading className="mx-0 my-[30px] p-0 text-center font-normal text-[24px] text-black">
|
||||
Your account on {env.PROJECT_NAME} has just been created!
|
||||
</Heading>
|
||||
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
<strong>Email: </strong>{email}
|
||||
</Text>
|
||||
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
<strong>Default password: </strong>{password}
|
||||
</Text>
|
||||
|
||||
<Section className="mt-[32px] mb-[32px] text-center">
|
||||
<Button
|
||||
className="rounded bg-[#000000] px-5 py-3 text-center font-semibold text-[12px] text-white no-underline"
|
||||
href={baseUrl}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
</Section>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailCreateUser;
|
||||
@@ -10,11 +10,19 @@ export const EmailLayout = ({ children, preview }: PropsWithChildren<{ preview?:
|
||||
<Tailwind>
|
||||
<Html>
|
||||
<Head />
|
||||
{preview ? <Preview>{preview}</Preview> : <Preview>Please check your mails</Preview>}
|
||||
<Body className="bg-gray-100 py-4" style={{ fontFamily: "Arial, sans-serif" }}>
|
||||
<Container className="bg-white border border-gray-200 p-12">
|
||||
<Img src={`${baseUrl}/images/logo-black.png`} width="200" height="auto" alt="Logo" />
|
||||
<Section>{children}</Section>
|
||||
<Body className="mx-auto my-auto bg-white px-2 font-sans">
|
||||
{preview ? <Preview>{preview}</Preview> : <Preview>Please check your mails</Preview>}
|
||||
<Container className="mx-auto my-[40px] max-w-[465px] rounded border border-[#eaeaea] border-solid p-[20px]">
|
||||
<Section className="mt-[32px]">
|
||||
<Img
|
||||
src={`${baseUrl}/images/logo.png`}
|
||||
width="50"
|
||||
height="auto"
|
||||
alt="Logo"
|
||||
className="mx-auto my-0"
|
||||
/>
|
||||
</Section>
|
||||
{children}
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import * as React from "react";
|
||||
import EmailLayout from "./email-layout";
|
||||
import {Text, Section, Button} from "@react-email/components";
|
||||
|
||||
export interface EmailResetPasswordProps {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const EmailResetPassword = ({url}: EmailResetPasswordProps) => {
|
||||
return (
|
||||
<EmailLayout preview="Email for password reset of your Portabase account">
|
||||
<Text className="text-base font-bold ">Hello !</Text>
|
||||
<Text className="text-base font-light ">You are receiving this email because we
|
||||
received a password reset request for your account.</Text>{" "}
|
||||
<Section className="mt-[32px] mb-[32px] text-center">
|
||||
<Button
|
||||
className="rounded bg-[#000000] px-5 py-3 text-center font-semibold text-[12px] text-white no-underline"
|
||||
href={url}
|
||||
>
|
||||
Reset Password
|
||||
</Button>
|
||||
</Section>
|
||||
<Text className="text-base font-light ">If you did not request a password reset, no
|
||||
further action is required.</Text>
|
||||
<Text className="text-base font-light ">Regards,<br/>Portabase</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailResetPassword;
|
||||
@@ -0,0 +1,850 @@
|
||||
"use client"
|
||||
import {cn} from "@/lib/utils";
|
||||
import {
|
||||
createContext,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useContext,
|
||||
useId,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Accept,
|
||||
FileRejection,
|
||||
useDropzone as rootUseDropzone,
|
||||
} from "react-dropzone";
|
||||
import {Button, ButtonVariantsProps} from "@/components/ui/button";
|
||||
|
||||
type DropzoneResult<TUploadRes, TUploadError> =
|
||||
| {
|
||||
status: "pending";
|
||||
}
|
||||
| {
|
||||
status: "error";
|
||||
error: TUploadError;
|
||||
}
|
||||
| {
|
||||
status: "success";
|
||||
result: TUploadRes;
|
||||
};
|
||||
|
||||
export type FileStatus<TUploadRes, TUploadError> = {
|
||||
id: string;
|
||||
fileName: string;
|
||||
file: File;
|
||||
tries: number;
|
||||
} & (
|
||||
| {
|
||||
status: "pending";
|
||||
result?: undefined;
|
||||
error?: undefined;
|
||||
}
|
||||
| {
|
||||
status: "error";
|
||||
error: TUploadError;
|
||||
result?: undefined;
|
||||
}
|
||||
| {
|
||||
status: "success";
|
||||
result: TUploadRes;
|
||||
error?: undefined;
|
||||
}
|
||||
);
|
||||
|
||||
const fileStatusReducer = <TUploadRes, TUploadError>(
|
||||
state: FileStatus<TUploadRes, TUploadError>[],
|
||||
action:
|
||||
| {
|
||||
type: "add";
|
||||
id: string;
|
||||
fileName: string;
|
||||
file: File;
|
||||
}
|
||||
| {
|
||||
type: "remove";
|
||||
id: string;
|
||||
}
|
||||
| ({
|
||||
type: "update-status";
|
||||
id: string;
|
||||
} & DropzoneResult<TUploadRes, TUploadError>),
|
||||
): FileStatus<TUploadRes, TUploadError>[] => {
|
||||
switch (action.type) {
|
||||
case "add":
|
||||
return [
|
||||
...state,
|
||||
{
|
||||
id: action.id,
|
||||
fileName: action.fileName,
|
||||
file: action.file,
|
||||
status: "pending",
|
||||
tries: 1,
|
||||
},
|
||||
];
|
||||
case "remove":
|
||||
return state.filter((fileStatus) => fileStatus.id !== action.id);
|
||||
case "update-status":
|
||||
return state.map((fileStatus) => {
|
||||
if (fileStatus.id === action.id) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const {id, type, ...rest} = action;
|
||||
return {
|
||||
...fileStatus,
|
||||
...rest,
|
||||
tries:
|
||||
action.status === "pending"
|
||||
? fileStatus.tries + 1
|
||||
: fileStatus.tries,
|
||||
} as FileStatus<TUploadRes, TUploadError>;
|
||||
}
|
||||
return fileStatus;
|
||||
});
|
||||
}
|
||||
};
|
||||
type DropZoneErrorCode = (typeof dropZoneErrorCodes)[number];
|
||||
const dropZoneErrorCodes = [
|
||||
"file-invalid-type",
|
||||
"file-too-large",
|
||||
"file-too-small",
|
||||
"too-many-files",
|
||||
] as const;
|
||||
|
||||
const getDropZoneErrorCodes = (fileRejections: FileRejection[]) => {
|
||||
const errors = fileRejections.map((rejection) => {
|
||||
return rejection.errors
|
||||
.filter((error) =>
|
||||
dropZoneErrorCodes.includes(error.code as DropZoneErrorCode),
|
||||
)
|
||||
.map((error) => error.code) as DropZoneErrorCode[];
|
||||
});
|
||||
return Array.from(new Set(errors.flat()));
|
||||
};
|
||||
|
||||
const getRootError = (
|
||||
errorCodes: DropZoneErrorCode[],
|
||||
limits: {
|
||||
accept?: Accept;
|
||||
maxSize?: number;
|
||||
minSize?: number;
|
||||
maxFiles?: number;
|
||||
},
|
||||
) => {
|
||||
const errors = errorCodes.map((error) => {
|
||||
switch (error) {
|
||||
case "file-invalid-type":
|
||||
const acceptedTypes = Object.values(limits.accept ?? {})
|
||||
.flat()
|
||||
.join(", ");
|
||||
return `only ${acceptedTypes} are allowed`;
|
||||
case "file-too-large":
|
||||
const maxMb = limits.maxSize
|
||||
? (limits.maxSize / (1024 * 1024)).toFixed(2)
|
||||
: "infinite?";
|
||||
return `max size is ${maxMb}MB`;
|
||||
case "file-too-small":
|
||||
const roundedMinSize = limits.minSize
|
||||
? (limits.minSize / (1024 * 1024)).toFixed(2)
|
||||
: "negative?";
|
||||
return `min size is ${roundedMinSize}MB`;
|
||||
case "too-many-files":
|
||||
return `max ${limits.maxFiles} files`;
|
||||
}
|
||||
});
|
||||
const joinedErrors = errors.join(", ");
|
||||
return joinedErrors.charAt(0).toUpperCase() + joinedErrors.slice(1);
|
||||
};
|
||||
|
||||
type UseDropzoneProps<TUploadRes, TUploadError> = {
|
||||
onDropFile: (
|
||||
file: File,
|
||||
) => Promise<
|
||||
Exclude<DropzoneResult<TUploadRes, TUploadError>, { status: "pending" }>
|
||||
>;
|
||||
onRemoveFile?: (id: string) => void | Promise<void>;
|
||||
onFileUploaded?: (result: TUploadRes) => void;
|
||||
onFileUploadError?: (error: TUploadError) => void;
|
||||
onAllUploaded?: () => void;
|
||||
onRootError?: (error: string | undefined) => void;
|
||||
maxRetryCount?: number;
|
||||
autoRetry?: boolean;
|
||||
validation?: {
|
||||
accept?: Accept;
|
||||
minSize?: number;
|
||||
maxSize?: number;
|
||||
maxFiles?: number;
|
||||
};
|
||||
shiftOnMaxFiles?: boolean;
|
||||
} & (TUploadError extends string
|
||||
? {
|
||||
shapeUploadError?: (error: TUploadError) => string | void;
|
||||
}
|
||||
: {
|
||||
shapeUploadError: (error: TUploadError) => string | void;
|
||||
});
|
||||
|
||||
interface UseDropzoneReturn<TUploadRes, TUploadError> {
|
||||
getRootProps: ReturnType<typeof rootUseDropzone>["getRootProps"];
|
||||
getInputProps: ReturnType<typeof rootUseDropzone>["getInputProps"];
|
||||
onRemoveFile: (id: string) => Promise<void>;
|
||||
onRetry: (id: string) => Promise<void>;
|
||||
canRetry: (id: string) => boolean;
|
||||
fileStatuses: FileStatus<TUploadRes, TUploadError>[];
|
||||
isInvalid: boolean;
|
||||
isDragActive: boolean;
|
||||
rootError: string | undefined;
|
||||
inputId: string;
|
||||
rootMessageId: string;
|
||||
rootDescriptionId: string;
|
||||
getFileMessageId: (id: string) => string;
|
||||
}
|
||||
|
||||
const useDropzone = <TUploadRes, TUploadError = string>(
|
||||
props: UseDropzoneProps<TUploadRes, TUploadError>,
|
||||
): UseDropzoneReturn<TUploadRes, TUploadError> => {
|
||||
const {
|
||||
onDropFile: pOnDropFile,
|
||||
onRemoveFile: pOnRemoveFile,
|
||||
shapeUploadError: pShapeUploadError,
|
||||
onFileUploaded: pOnFileUploaded,
|
||||
onFileUploadError: pOnFileUploadError,
|
||||
onAllUploaded: pOnAllUploaded,
|
||||
onRootError: pOnRootError,
|
||||
maxRetryCount,
|
||||
autoRetry,
|
||||
validation,
|
||||
shiftOnMaxFiles,
|
||||
} = props;
|
||||
|
||||
const inputId = useId();
|
||||
const rootMessageId = `${inputId}-root-message`;
|
||||
const rootDescriptionId = `${inputId}-description`;
|
||||
const [rootError, _setRootError] = useState<string | undefined>(undefined);
|
||||
|
||||
const setRootError = useCallback(
|
||||
(error: string | undefined) => {
|
||||
_setRootError(error);
|
||||
if (pOnRootError !== undefined) {
|
||||
pOnRootError(error);
|
||||
}
|
||||
},
|
||||
[pOnRootError, _setRootError],
|
||||
);
|
||||
|
||||
const [fileStatuses, dispatch] = useReducer(fileStatusReducer, []);
|
||||
|
||||
const isInvalid = useMemo(() => {
|
||||
return (
|
||||
fileStatuses.filter((file) => file.status === "error").length > 0 ||
|
||||
rootError !== undefined
|
||||
);
|
||||
}, [fileStatuses, rootError]);
|
||||
|
||||
const _uploadFile = useCallback(
|
||||
async (file: File, id: string, tries = 0) => {
|
||||
const result = await pOnDropFile(file);
|
||||
|
||||
if (result.status === "error") {
|
||||
if (autoRetry === true && tries < (maxRetryCount ?? Infinity)) {
|
||||
dispatch({type: "update-status", id, status: "pending"});
|
||||
return _uploadFile(file, id, tries + 1);
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: "update-status",
|
||||
id,
|
||||
status: "error",
|
||||
error:
|
||||
pShapeUploadError !== undefined
|
||||
? pShapeUploadError(result.error)
|
||||
: result.error,
|
||||
});
|
||||
if (pOnFileUploadError !== undefined) {
|
||||
pOnFileUploadError(result.error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (pOnFileUploaded !== undefined) {
|
||||
pOnFileUploaded(result.result);
|
||||
}
|
||||
dispatch({
|
||||
type: "update-status",
|
||||
id,
|
||||
...result,
|
||||
});
|
||||
},
|
||||
[
|
||||
autoRetry,
|
||||
maxRetryCount,
|
||||
pOnDropFile,
|
||||
pShapeUploadError,
|
||||
pOnFileUploadError,
|
||||
pOnFileUploaded,
|
||||
],
|
||||
);
|
||||
|
||||
const onRemoveFile = useCallback(
|
||||
async (id: string) => {
|
||||
await pOnRemoveFile?.(id);
|
||||
dispatch({type: "remove", id});
|
||||
},
|
||||
[pOnRemoveFile],
|
||||
);
|
||||
|
||||
const canRetry = useCallback(
|
||||
(id: string) => {
|
||||
const fileStatus = fileStatuses.find((file) => file.id === id);
|
||||
return (
|
||||
fileStatus?.status === "error" &&
|
||||
fileStatus.tries < (maxRetryCount ?? Infinity)
|
||||
);
|
||||
},
|
||||
[fileStatuses, maxRetryCount],
|
||||
);
|
||||
|
||||
const onRetry = useCallback(
|
||||
async (id: string) => {
|
||||
if (!canRetry(id)) {
|
||||
return;
|
||||
}
|
||||
dispatch({type: "update-status", id, status: "pending"});
|
||||
const fileStatus = fileStatuses.find((file) => file.id === id);
|
||||
if (!fileStatus || fileStatus.status !== "error") {
|
||||
return;
|
||||
}
|
||||
await _uploadFile(fileStatus.file, id);
|
||||
},
|
||||
[canRetry, fileStatuses, _uploadFile],
|
||||
);
|
||||
|
||||
const getFileMessageId = (id: string) => `${inputId}-${id}-message`;
|
||||
|
||||
const dropzone = rootUseDropzone({
|
||||
accept: validation?.accept,
|
||||
minSize: validation?.minSize,
|
||||
maxSize: validation?.maxSize,
|
||||
onDropAccepted: async (newFiles) => {
|
||||
setRootError(undefined);
|
||||
|
||||
// useDropzone hook only checks max file count per group of uploaded files, allows going over if in multiple batches
|
||||
const fileCount = fileStatuses.length;
|
||||
const maxNewFiles =
|
||||
validation?.maxFiles === undefined
|
||||
? Infinity
|
||||
: validation?.maxFiles - fileCount;
|
||||
|
||||
if (maxNewFiles < newFiles.length) {
|
||||
if (shiftOnMaxFiles === true) {
|
||||
} else {
|
||||
setRootError(getRootError(["too-many-files"], validation ?? {}));
|
||||
}
|
||||
}
|
||||
|
||||
const slicedNewFiles =
|
||||
shiftOnMaxFiles === true ? newFiles : newFiles.slice(0, maxNewFiles);
|
||||
|
||||
const onDropFilePromises = slicedNewFiles.map(async (file, index) => {
|
||||
if (fileCount + 1 > maxNewFiles) {
|
||||
await onRemoveFile(fileStatuses[index].id);
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
dispatch({type: "add", fileName: file.name, file, id});
|
||||
await _uploadFile(file, id);
|
||||
});
|
||||
|
||||
await Promise.all(onDropFilePromises);
|
||||
if (pOnAllUploaded !== undefined) {
|
||||
pOnAllUploaded();
|
||||
}
|
||||
},
|
||||
onDropRejected: (fileRejections) => {
|
||||
const errorMessage = getRootError(
|
||||
getDropZoneErrorCodes(fileRejections),
|
||||
validation ?? {}
|
||||
);
|
||||
setRootError(errorMessage);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
getRootProps: dropzone.getRootProps,
|
||||
getInputProps: dropzone.getInputProps,
|
||||
inputId,
|
||||
rootMessageId,
|
||||
rootDescriptionId,
|
||||
getFileMessageId,
|
||||
onRemoveFile,
|
||||
onRetry,
|
||||
canRetry,
|
||||
fileStatuses: fileStatuses as FileStatus<TUploadRes, TUploadError>[],
|
||||
isInvalid,
|
||||
rootError,
|
||||
isDragActive: dropzone.isDragActive,
|
||||
};
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const DropZoneContext = createContext<UseDropzoneReturn<any, any>>({
|
||||
getRootProps: () => ({}) as never,
|
||||
getInputProps: () => ({}) as never,
|
||||
onRemoveFile: async () => {
|
||||
},
|
||||
onRetry: async () => {
|
||||
},
|
||||
canRetry: () => false,
|
||||
fileStatuses: [],
|
||||
isInvalid: false,
|
||||
isDragActive: false,
|
||||
rootError: undefined,
|
||||
inputId: "",
|
||||
rootMessageId: "",
|
||||
rootDescriptionId: "",
|
||||
getFileMessageId: () => "",
|
||||
});
|
||||
|
||||
const useDropzoneContext = <TUploadRes, TUploadError>() => {
|
||||
return useContext(DropZoneContext) as UseDropzoneReturn<
|
||||
TUploadRes,
|
||||
TUploadError
|
||||
>;
|
||||
};
|
||||
|
||||
interface DropzoneProps<TUploadRes, TUploadError>
|
||||
extends UseDropzoneReturn<TUploadRes, TUploadError> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Dropzone = <TUploadRes, TUploadError>(
|
||||
props: DropzoneProps<TUploadRes, TUploadError>,
|
||||
) => {
|
||||
const {children, ...rest} = props;
|
||||
return (
|
||||
<DropZoneContext.Provider value={rest}>{children}</DropZoneContext.Provider>
|
||||
);
|
||||
};
|
||||
Dropzone.displayName = "Dropzone";
|
||||
|
||||
interface DropZoneAreaProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
}
|
||||
|
||||
const DropZoneArea = forwardRef<HTMLDivElement, DropZoneAreaProps>(
|
||||
({className, children, ...props}, forwardedRef) => {
|
||||
const context = useDropzoneContext();
|
||||
|
||||
if (!context) {
|
||||
throw new Error("DropzoneArea must be used within a Dropzone");
|
||||
}
|
||||
|
||||
const {onFocus, onBlur, onDragEnter, onDragLeave, onDrop, ref} =
|
||||
context.getRootProps();
|
||||
|
||||
return (
|
||||
// A11y behavior is handled through Trigger. All of these are only relevant to drag and drop which means this should be fine?
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
ref={(instance) => {
|
||||
// TODO: test if this actually works?
|
||||
ref.current = instance;
|
||||
if (typeof forwardedRef === "function") {
|
||||
forwardedRef(instance);
|
||||
} else if (forwardedRef) {
|
||||
forwardedRef.current = instance;
|
||||
}
|
||||
}}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onDragEnter={onDragEnter}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
{...props}
|
||||
aria-label="dropzone"
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
context.isDragActive && "animate-pulse bg-black/5",
|
||||
context.isInvalid && "border-destructive",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
DropZoneArea.displayName = "DropZoneArea";
|
||||
|
||||
export interface DropzoneDescriptionProps
|
||||
extends React.HTMLAttributes<HTMLParagraphElement> {
|
||||
}
|
||||
|
||||
const DropzoneDescription = forwardRef<
|
||||
HTMLParagraphElement,
|
||||
DropzoneDescriptionProps
|
||||
>((props, ref) => {
|
||||
const {className, ...rest} = props;
|
||||
const context = useDropzoneContext();
|
||||
if (!context) {
|
||||
throw new Error("DropzoneDescription must be used within a Dropzone");
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={context.rootDescriptionId}
|
||||
{...rest}
|
||||
className={cn("pb-1 text-sm text-muted-foreground", className)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
DropzoneDescription.displayName = "DropzoneDescription";
|
||||
|
||||
interface DropzoneFileListContext<TUploadRes, TUploadError> {
|
||||
onRemoveFile: () => Promise<void>;
|
||||
onRetry: () => Promise<void>;
|
||||
fileStatus: FileStatus<TUploadRes, TUploadError>;
|
||||
canRetry: boolean;
|
||||
dropzoneId: string;
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
const DropzoneFileListContext = createContext<
|
||||
DropzoneFileListContext<unknown, unknown>
|
||||
>({
|
||||
onRemoveFile: async () => {
|
||||
},
|
||||
onRetry: async () => {
|
||||
},
|
||||
fileStatus: {} as FileStatus<unknown, unknown>,
|
||||
canRetry: false,
|
||||
dropzoneId: "",
|
||||
messageId: "",
|
||||
});
|
||||
|
||||
const useDropzoneFileListContext = () => {
|
||||
return useContext(DropzoneFileListContext);
|
||||
};
|
||||
|
||||
interface DropZoneFileListProps
|
||||
extends React.OlHTMLAttributes<HTMLOListElement> {
|
||||
}
|
||||
|
||||
const DropzoneFileList = forwardRef<HTMLOListElement, DropZoneFileListProps>(
|
||||
(props, ref) => {
|
||||
const context = useDropzoneContext();
|
||||
if (!context) {
|
||||
throw new Error("DropzoneFileList must be used within a Dropzone");
|
||||
}
|
||||
return (
|
||||
<ol
|
||||
ref={ref}
|
||||
aria-label="dropzone-file-list"
|
||||
{...props}
|
||||
className={cn("flex flex-col gap-4", props.className)}
|
||||
>
|
||||
{props.children}
|
||||
</ol>
|
||||
);
|
||||
},
|
||||
);
|
||||
DropzoneFileList.displayName = "DropzoneFileList";
|
||||
|
||||
interface DropzoneFileListItemProps<TUploadRes, TUploadError>
|
||||
extends React.LiHTMLAttributes<HTMLLIElement> {
|
||||
file: FileStatus<TUploadRes, TUploadError>;
|
||||
}
|
||||
|
||||
const DropzoneFileListItem = forwardRef<
|
||||
HTMLLIElement,
|
||||
DropzoneFileListItemProps<unknown, unknown>
|
||||
>(({className, ...props}, ref) => {
|
||||
const fileId = props.file.id;
|
||||
const {
|
||||
onRemoveFile: cOnRemoveFile,
|
||||
onRetry: cOnRetry,
|
||||
getFileMessageId: cGetFileMessageId,
|
||||
canRetry: cCanRetry,
|
||||
inputId: cInputId,
|
||||
} = useDropzoneContext();
|
||||
|
||||
const onRemoveFile = useCallback(
|
||||
() => cOnRemoveFile(fileId),
|
||||
[fileId, cOnRemoveFile],
|
||||
);
|
||||
const onRetry = useCallback(() => cOnRetry(fileId), [fileId, cOnRetry]);
|
||||
const messageId = cGetFileMessageId(fileId);
|
||||
const isInvalid = props.file.status === "error";
|
||||
const canRetry = useMemo(() => cCanRetry(fileId), [fileId, cCanRetry]);
|
||||
return (
|
||||
<DropzoneFileListContext.Provider
|
||||
value={{
|
||||
onRemoveFile,
|
||||
onRetry,
|
||||
fileStatus: props.file,
|
||||
canRetry,
|
||||
dropzoneId: cInputId,
|
||||
messageId,
|
||||
}}
|
||||
>
|
||||
<li
|
||||
ref={ref}
|
||||
aria-label="dropzone-file-list-item"
|
||||
aria-describedby={isInvalid ? messageId : undefined}
|
||||
className={cn(
|
||||
"flex flex-col justify-center gap-2 rounded-md bg-muted/40 px-4 py-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</li>
|
||||
</DropzoneFileListContext.Provider>
|
||||
);
|
||||
});
|
||||
DropzoneFileListItem.displayName = "DropzoneFileListItem";
|
||||
|
||||
interface DropzoneFileMessageProps
|
||||
extends React.HTMLAttributes<HTMLParagraphElement> {
|
||||
}
|
||||
|
||||
const DropzoneFileMessage = forwardRef<
|
||||
HTMLParagraphElement,
|
||||
DropzoneFileMessageProps
|
||||
>((props, ref) => {
|
||||
const {children, ...rest} = props;
|
||||
const context = useDropzoneFileListContext();
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"DropzoneFileMessage must be used within a DropzoneFileListItem",
|
||||
);
|
||||
}
|
||||
|
||||
const body =
|
||||
context.fileStatus.status === "error"
|
||||
? String(context.fileStatus.error)
|
||||
: children;
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={context.messageId}
|
||||
{...rest}
|
||||
className={cn(
|
||||
"h-5 text-[0.8rem] font-medium text-destructive",
|
||||
rest.className,
|
||||
)}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
});
|
||||
DropzoneFileMessage.displayName = "DropzoneFileMessage";
|
||||
|
||||
interface DropzoneMessageProps
|
||||
extends React.HTMLAttributes<HTMLParagraphElement> {
|
||||
}
|
||||
|
||||
const DropzoneMessage = forwardRef<HTMLParagraphElement, DropzoneMessageProps>(
|
||||
(props, ref) => {
|
||||
const {children, ...rest} = props;
|
||||
const context = useDropzoneContext();
|
||||
if (!context) {
|
||||
throw new Error("DropzoneRootMessage must be used within a Dropzone");
|
||||
}
|
||||
|
||||
const body = context.rootError ? String(context.rootError) : children;
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={context.rootMessageId}
|
||||
{...rest}
|
||||
className={cn(
|
||||
"h-5 text-[0.8rem] font-medium text-destructive",
|
||||
rest.className,
|
||||
)}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
},
|
||||
);
|
||||
DropzoneMessage.displayName = "DropzoneMessage";
|
||||
|
||||
interface DropzoneRemoveFileProps extends ButtonVariantsProps {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const DropzoneRemoveFile = forwardRef<
|
||||
HTMLButtonElement,
|
||||
DropzoneRemoveFileProps
|
||||
>(({className, ...props}, ref) => {
|
||||
const context = useDropzoneFileListContext();
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"DropzoneRemoveFile must be used within a DropzoneFileListItem",
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
onClick={context.onRemoveFile}
|
||||
type="button"
|
||||
size="icon"
|
||||
{...props}
|
||||
className={cn(
|
||||
"aria-disabled:pointer-events-none aria-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
<span className="sr-only">Remove file</span>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
DropzoneRemoveFile.displayName = "DropzoneRemoveFile";
|
||||
|
||||
interface DropzoneRetryFileProps extends ButtonVariantsProps {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const DropzoneRetryFile = forwardRef<HTMLButtonElement, DropzoneRetryFileProps>(
|
||||
({className, ...props}, ref) => {
|
||||
const context = useDropzoneFileListContext();
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"DropzoneRetryFile must be used within a DropzoneFileListItem",
|
||||
);
|
||||
}
|
||||
|
||||
const canRetry = context.canRetry;
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
aria-disabled={!canRetry}
|
||||
aria-label="retry"
|
||||
onClick={context.onRetry}
|
||||
type="button"
|
||||
size="icon"
|
||||
{...props}
|
||||
className={cn(
|
||||
"aria-disabled:pointer-events-none aria-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
<span className="sr-only">Retry</span>
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
);
|
||||
DropzoneRetryFile.displayName = "DropzoneRetryFile";
|
||||
|
||||
interface DropzoneTriggerProps
|
||||
extends React.LabelHTMLAttributes<HTMLLabelElement> {
|
||||
}
|
||||
|
||||
const DropzoneTrigger = forwardRef<HTMLLabelElement, DropzoneTriggerProps>(
|
||||
({className, children, ...props}, ref) => {
|
||||
const context = useDropzoneContext();
|
||||
if (!context) {
|
||||
throw new Error("DropzoneTrigger must be used within a Dropzone");
|
||||
}
|
||||
|
||||
const {fileStatuses, getFileMessageId} = context;
|
||||
|
||||
const fileMessageIds = useMemo(
|
||||
() =>
|
||||
fileStatuses
|
||||
.filter((file) => file.status === "error")
|
||||
.map((file) => getFileMessageId(file.id)),
|
||||
[fileStatuses, getFileMessageId],
|
||||
);
|
||||
|
||||
return (
|
||||
<label
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={cn(
|
||||
"cursor-pointer rounded-sm bg-secondary px-4 py-2 font-medium ring-offset-background transition-colors focus-within:outline-none hover:bg-secondary/80 has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-ring has-[input:focus-visible]:ring-offset-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<input
|
||||
{...context.getInputProps({
|
||||
style: {
|
||||
display: undefined,
|
||||
},
|
||||
className: "sr-only",
|
||||
tabIndex: undefined,
|
||||
})}
|
||||
aria-describedby={
|
||||
context.isInvalid
|
||||
? [context.rootMessageId, ...fileMessageIds].join(" ")
|
||||
: undefined
|
||||
}
|
||||
aria-invalid={context.isInvalid}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
},
|
||||
);
|
||||
DropzoneTrigger.displayName = "DropzoneTrigger";
|
||||
|
||||
interface InfiniteProgressProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
status: "pending" | "success" | "error";
|
||||
}
|
||||
|
||||
const valueTextMap = {
|
||||
pending: "indeterminate",
|
||||
success: "100%",
|
||||
error: "error",
|
||||
};
|
||||
|
||||
const InfiniteProgress = forwardRef<HTMLDivElement, InfiniteProgressProps>(
|
||||
({className, ...props}, ref) => {
|
||||
const done = props.status === "success" || props.status === "error";
|
||||
const error = props.status === "error";
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuetext={valueTextMap[props.status]}
|
||||
{...props}
|
||||
className={cn(
|
||||
"relative h-2 w-full overflow-hidden rounded-full bg-muted",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
// TODO: add proper done transition
|
||||
className={cn(
|
||||
"h-full w-full rounded-full bg-primary",
|
||||
done ? "translate-x-0" : "animate-infinite-progress",
|
||||
error && "bg-destructive",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
InfiniteProgress.displayName = "InfiniteProgress";
|
||||
|
||||
export {
|
||||
Dropzone,
|
||||
DropZoneArea,
|
||||
DropzoneDescription,
|
||||
DropzoneFileList,
|
||||
DropzoneFileListItem,
|
||||
DropzoneFileMessage,
|
||||
DropzoneMessage,
|
||||
DropzoneRemoveFile,
|
||||
DropzoneRetryFile,
|
||||
DropzoneTrigger,
|
||||
InfiniteProgress,
|
||||
useDropzone,
|
||||
};
|
||||
@@ -1,31 +1,49 @@
|
||||
"use client"
|
||||
import {env} from "@/env.mjs";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {useTheme} from "next-themes";
|
||||
"use client";
|
||||
|
||||
import {env} from "@/env.mjs";
|
||||
import {useTheme} from "next-themes";
|
||||
import Image from "next/image";
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
export const AuthLogoSection = () => {
|
||||
|
||||
const {resolvedTheme} = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
const imageTheme =
|
||||
resolvedTheme === "dark"
|
||||
? "/images/logo-dark.png"
|
||||
: "/images/logo-light.png";
|
||||
|
||||
const handleLoad = () => setLoaded(true);
|
||||
|
||||
const style = {
|
||||
transition: "opacity 0.3s ease-in-out",
|
||||
opacity: loaded ? 1 : 0,
|
||||
};
|
||||
|
||||
const imageTheme = resolvedTheme === "dark" ? "/images/logo-white.png" : "/images/logo-black.png";
|
||||
|
||||
return (
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md flex items-center justify-center space-x-2">
|
||||
<img
|
||||
className="p-12 text-black dark:text-white"
|
||||
src={imageTheme}
|
||||
alt="Logo"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground -ml-12 -mb-12">v{env.NEXT_PUBLIC_PROJECT_VERSION}</span>
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md relative flex items-center justify-center h-[160px]">
|
||||
{mounted && (
|
||||
<Image
|
||||
src={imageTheme}
|
||||
alt="Logo"
|
||||
fill
|
||||
priority
|
||||
className="object-contain p-10"
|
||||
onLoad={handleLoad}
|
||||
style={style}
|
||||
|
||||
/>
|
||||
)}
|
||||
<span className="absolute bottom-10 right-5 text-sm text-muted-foreground" style={style}>
|
||||
v{env.NEXT_PUBLIC_PROJECT_VERSION}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ export const ForgotPasswordForm = (props: ForgotPasswordFormProps) => {
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Password reset successfully.");
|
||||
toast.success("If an account with this email address exists, you will receive an email with instructions to reset your password.");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.error.message);
|
||||
|
||||
@@ -28,7 +28,6 @@ export const resetPasswordAction = action
|
||||
}
|
||||
|
||||
const user = await (await auth.$context).internalAdapter.findUserById(verification.value);
|
||||
console.log(user)
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -39,28 +38,6 @@ export const resetPasswordAction = action
|
||||
};
|
||||
}
|
||||
|
||||
const hashedPassword = await (await auth.$context).password.hash(parsedInput.schema.password);
|
||||
console.log(hashedPassword)
|
||||
console.log("ok")
|
||||
// await (await auth.$context).internalAdapter.updatePassword(user.id, hashedPassword);
|
||||
console.log("ici")
|
||||
// await (await auth.$context).internalAdapter.deleteSessions(user.id);
|
||||
// console.log("ici2")
|
||||
// await (await auth.$context).internalAdapter.deleteVerificationValue(verification.id);
|
||||
// console.log("ici3");
|
||||
//
|
||||
// (await auth.$context).internalAdapter.updateUser(user.id, {
|
||||
// lastChangedPasswordAt: new Date(),
|
||||
// });
|
||||
|
||||
// await auth.api.resetPassword({
|
||||
// headers: await headers(),
|
||||
// body: {
|
||||
// newPassword: parsedInput.schema.password,
|
||||
// token: parsedInput.token,
|
||||
// },
|
||||
// });
|
||||
|
||||
await (
|
||||
await auth.$context
|
||||
).internalAdapter.updateUser(user.id, {
|
||||
|
||||
@@ -44,7 +44,6 @@ export const ResetPasswordForm = (props: ResetPasswordFormProps) => {
|
||||
setTimeout(() => router.push("/"), 1400);
|
||||
},
|
||||
onError: (error: BetterAuthError) => {
|
||||
console.log(error)
|
||||
toast.error("An error occurred while resetting password");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import {RegisterSchema, RegisterType} from "@/components/wrappers/auth/register/
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
import {signUp} from "@/lib/auth/auth-client";
|
||||
import Link from "next/link";
|
||||
import {CardAuth} from "@/features/layout/card-auth";
|
||||
|
||||
export type registerFormProps = {
|
||||
defaultValues?: RegisterType;
|
||||
@@ -35,7 +36,6 @@ export const RegisterForm = (props: registerFormProps) => {
|
||||
router.push(`/login`);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.log(error);
|
||||
toast.error(error.error.message);
|
||||
},
|
||||
});
|
||||
@@ -45,7 +45,7 @@ export const RegisterForm = (props: registerFormProps) => {
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Card>
|
||||
<CardAuth>
|
||||
<CardHeader>
|
||||
<div className="grid gap-2 text-center mb-2">
|
||||
<h1 className="text-3xl font-bold">Create an account</h1>
|
||||
@@ -144,7 +144,7 @@ export const RegisterForm = (props: registerFormProps) => {
|
||||
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardAuth>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,10 +6,11 @@ import {Form, FormControl, FormField, FormItem, FormLabel, useZodForm} from "@/c
|
||||
import {PasswordStrengthInput} from "@/components/ui/password-input-indicator";
|
||||
import {ResetPasswordSchema, ResetPasswordType} from "@/components/wrappers/auth/reset-password/reset-password-schema";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
import {Card, CardContent, CardHeader} from "@/components/ui/card";
|
||||
import {CardContent, CardHeader} from "@/components/ui/card";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {authClient} from "@/lib/auth/auth-client";
|
||||
import {toast} from "sonner";
|
||||
import {CardAuth} from "@/features/layout/card-auth";
|
||||
|
||||
type ResetPasswordFormProps = {
|
||||
token: string;
|
||||
@@ -30,7 +31,6 @@ export const ResetPasswordForm = ({token}: ResetPasswordFormProps) => {
|
||||
token,
|
||||
}, {
|
||||
onSuccess: (response) => {
|
||||
console.log(response);
|
||||
toast.success("Password changed successfully.");
|
||||
setTimeout(() => router.push("/"), 1000);
|
||||
},
|
||||
@@ -43,7 +43,7 @@ export const ResetPasswordForm = ({token}: ResetPasswordFormProps) => {
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardAuth>
|
||||
<CardHeader>
|
||||
<div className="grid gap-2 text-center mb-2">
|
||||
<h1 className="text-3xl font-bold">Reset Password</h1>
|
||||
@@ -89,6 +89,6 @@ export const ResetPasswordForm = ({token}: ResetPasswordFormProps) => {
|
||||
</ButtonWithLoading>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardAuth>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,9 +5,12 @@ import { Button } from "@/components/ui/button";
|
||||
import { authClient } from "@/lib/auth/auth-client";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {SUPPORTED_PROVIDERS} from "../../../../portabase.config";
|
||||
import {AuthProviderConfig} from "../../../../portabase.config";
|
||||
import {Icon} from "@iconify/react";
|
||||
|
||||
export function SocialAuthButtons({ providers }: { providers: AuthProviderConfig[] }) {
|
||||
const socialProviders = providers.filter(p => p.isActive && !p.isManual);
|
||||
|
||||
export function SocialAuthButtons() {
|
||||
const [isLoading, setIsLoading] = useState<string | null>(null);
|
||||
|
||||
const handleSocialSignIn = async (providerId: string) => {
|
||||
@@ -30,16 +33,15 @@ export function SocialAuthButtons() {
|
||||
}
|
||||
};
|
||||
|
||||
const socialProviders = SUPPORTED_PROVIDERS.filter((p) => !p.isManual);
|
||||
|
||||
if (socialProviders.length === 0) return null;
|
||||
|
||||
|
||||
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" /> : <provider.icon className="h-4 w-4" />}
|
||||
{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>
|
||||
))}
|
||||
|
||||
@@ -43,13 +43,17 @@ export function ComboBox<T = string>(props: ComboBoxProps<T>) {
|
||||
<PopoverTrigger asChild>
|
||||
{sideBar ? (
|
||||
<SidebarMenuButton>
|
||||
<label className="max-w-[170px] truncate">
|
||||
{value ? choices.find((c) => c.value === value)?.label : "Select choice..."}
|
||||
<ChevronDown className="ml-auto"/>
|
||||
</label>
|
||||
<ChevronDown className="ml-auto"/>
|
||||
</SidebarMenuButton>
|
||||
) : (
|
||||
<Button variant="outline" role="combobox" aria-expanded={open}
|
||||
className="w-full justify-between">
|
||||
{value ? choices.find((c) => c.value === value)?.label : "Select choice..."}
|
||||
<label className="max-w-[170px] truncate">
|
||||
{value ? choices.find((c) => c.value === value)?.label : "Select choice..."}
|
||||
</label>
|
||||
<ChevronDown className="opacity-50"/>
|
||||
</Button>
|
||||
)}
|
||||
@@ -59,7 +63,8 @@ export function ComboBox<T = string>(props: ComboBoxProps<T>) {
|
||||
className="p-0"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
style={{ width: 'var(--radix-popover-trigger-width)' }}
|
||||
style={{width: 'var(--radix-popover-trigger-width)'}}
|
||||
|
||||
>
|
||||
<Command>
|
||||
{searchField && <CommandInput placeholder="Search choice..." className="h-9"/>}
|
||||
@@ -79,7 +84,9 @@ export function ComboBox<T = string>(props: ComboBoxProps<T>) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{choice.label}
|
||||
<label className="max-w-[170px] truncate">
|
||||
{choice.label}
|
||||
</label>
|
||||
<Check
|
||||
className={cn("ml-auto", value === choice.value ? "opacity-100" : "opacity-0")}/>
|
||||
</CommandItem>
|
||||
|
||||
@@ -12,8 +12,6 @@ export const ConnectionCircle = ({date}: ConnectionCircleProps) => {
|
||||
const timestamp = date.getTime();
|
||||
const interval_seconds = (now - timestamp) / 1000;
|
||||
|
||||
console.log({now, timestamp, interval_seconds});
|
||||
|
||||
if (interval_seconds < 55) {
|
||||
style = "bg-green-400 border-green-600";
|
||||
} else if (interval_seconds <= 60) {
|
||||
@@ -25,7 +23,5 @@ export const ConnectionCircle = ({date}: ConnectionCircleProps) => {
|
||||
console.warn("Invalid date passed to ConnectionCircle:", date);
|
||||
}
|
||||
|
||||
console.log(style);
|
||||
|
||||
return <div className={cn("w-5 h-5 rounded-full border-4", style)}/>;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CircleCheck,
|
||||
CircleX,
|
||||
CloudUploadIcon,
|
||||
Loader2,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropZoneArea,
|
||||
DropzoneDescription,
|
||||
DropzoneFileList,
|
||||
DropzoneFileListItem,
|
||||
DropzoneMessage,
|
||||
DropzoneRemoveFile,
|
||||
DropzoneTrigger,
|
||||
useDropzone,
|
||||
Dropzone,
|
||||
} from "@/components/ui/dropzone";
|
||||
|
||||
type DropZoneFileProps = {
|
||||
onFileDropAction?: (file: File) => Promise<void> | void;
|
||||
onFileRemoveAction?: (file: File) => void;
|
||||
accept?: Record<string, string[]>;
|
||||
maxSize?: number;
|
||||
maxFiles?: number;
|
||||
description?: string;
|
||||
fileKind?: string;
|
||||
dragMessage?: string;
|
||||
fileList?: boolean;
|
||||
};
|
||||
|
||||
|
||||
export const DropZoneFile = ({
|
||||
onFileDropAction,
|
||||
onFileRemoveAction,
|
||||
accept = {"application/pdf": [".pdf"]},
|
||||
maxSize = 50 * 1024 * 1024,
|
||||
maxFiles = 1,
|
||||
description = "Please select a file",
|
||||
fileKind = "Upload files (.pdf)",
|
||||
dragMessage = "Click here or drag & drop to upload",
|
||||
fileList = true
|
||||
}: DropZoneFileProps) => {
|
||||
const dropzone = useDropzone({
|
||||
onDropFile: async (file: File) => {
|
||||
onFileDropAction?.(file);
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
result: URL.createObjectURL(file),
|
||||
};
|
||||
},
|
||||
validation: {
|
||||
accept,
|
||||
maxSize,
|
||||
maxFiles,
|
||||
},
|
||||
});
|
||||
|
||||
const handleRemove = (file: any) => {
|
||||
dropzone.onRemoveFile(file.id);
|
||||
onFileRemoveAction?.(file.file);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="not-prose flex flex-col gap-4 h-full">
|
||||
<Dropzone {...dropzone}>
|
||||
<div className="h-full">
|
||||
{dropzone.fileStatuses.length < maxFiles && (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex justify-between">
|
||||
<DropzoneDescription>{description}</DropzoneDescription>
|
||||
<DropzoneMessage/>
|
||||
</div>
|
||||
<DropZoneArea className="flex-1 px-0 py-0 mt-2">
|
||||
<DropzoneTrigger
|
||||
className="flex h-full w-full flex-col items-center justify-center gap-4 bg-transparent p-10 text-center text-sm">
|
||||
<CloudUploadIcon className="size-8"/>
|
||||
<div>
|
||||
<p className="font-semibold">{fileKind}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{dragMessage}
|
||||
</p>
|
||||
</div>
|
||||
</DropzoneTrigger>
|
||||
</DropZoneArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{fileList && (
|
||||
<DropzoneFileList className="grid gap-3 p-0">
|
||||
{dropzone.fileStatuses.map((file) => (
|
||||
<DropzoneFileListItem
|
||||
key={file.id}
|
||||
file={file}
|
||||
className="overflow-hidden rounded-md bg-secondary p-0 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between p-2 pl-4">
|
||||
<div className="flex overflow-hidden items-center gap-3">
|
||||
<div>
|
||||
{file.status === "pending" ? (
|
||||
<Loader2 className="animate-spin" size={16}/>
|
||||
) : file.status === "error" ? (
|
||||
<CircleX className="text-red-600" size={16}/>
|
||||
) : (
|
||||
<CircleCheck className="text-green-700" size={16}/>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm flex">
|
||||
<span>{file.fileName}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(file.file.size / (1024 * 1024)).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DropzoneRemoveFile
|
||||
variant="ghost"
|
||||
className="shrink-0 hover:outline"
|
||||
// @ts-ignore
|
||||
onClick={() => handleRemove(file)}
|
||||
>
|
||||
<Trash2Icon className="size-4"/>
|
||||
</DropzoneRemoveFile>
|
||||
</div>
|
||||
</DropzoneFileListItem>
|
||||
))}
|
||||
</DropzoneFileList>
|
||||
)}
|
||||
|
||||
</Dropzone>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -9,7 +9,7 @@ export const GitHubStarsButtonCustom = () => {
|
||||
const [stars, setStars] = React.useState(0);
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
|
||||
const username = "Soluce-Technologies"
|
||||
const username = "Portabase"
|
||||
const repo = "portabase"
|
||||
|
||||
|
||||
|
||||
@@ -29,10 +29,6 @@ export const NotifierChannelOrganisationForm = ({
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
|
||||
console.log("defaultValues", defaultValues);
|
||||
console.log("organizations", organizations);
|
||||
|
||||
const defaultOrganizationIds = defaultValues?.organizations?.map(organization => organization.organizationId) ?? []
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {Mail, MessageSquare, Webhook} from "lucide-react";
|
||||
import {Mail} from "lucide-react";
|
||||
import type { SVGProps } from 'react';
|
||||
|
||||
export const getNotificationChannelIcon = (type: string) => {
|
||||
const Icon = notificationTypes.find((t) => t.value === type)?.icon
|
||||
@@ -6,8 +7,481 @@ export const getNotificationChannelIcon = (type: string) => {
|
||||
}
|
||||
|
||||
|
||||
const notificationTypes = [
|
||||
export const notificationTypes = [
|
||||
{value: "smtp", label: "Email", icon: Mail},
|
||||
{value: "slack", label: "Slack", icon: MessageSquare},
|
||||
{value: "webhook", label: "Webhook", icon: Webhook},
|
||||
]
|
||||
{value: "slack", label: "Slack", icon: SlackIcon},
|
||||
{value: "discord", label: "Discord", icon: DiscordIcon},
|
||||
{value: "telegram", label: "Telegram", icon: TelegramIcon},
|
||||
{value: "gotify", label: "Gotify", icon: GotifyIcon},
|
||||
{value: "ntfy", label: "ntfy.sh", icon: NtfyIcon},
|
||||
{value: "webhook", label: "Webhook", icon: WebhookIcon},
|
||||
]
|
||||
|
||||
|
||||
export function SlackIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (<svg xmlns="http://www.w3.org/2000/svg" width={256} height={256} viewBox="0 0 256 256" {...props}><path fill="#e01e5a" d="M53.841 161.32c0 14.832-11.987 26.82-26.819 26.82S.203 176.152.203 161.32c0-14.831 11.987-26.818 26.82-26.818H53.84zm13.41 0c0-14.831 11.987-26.818 26.819-26.818s26.819 11.987 26.819 26.819v67.047c0 14.832-11.987 26.82-26.82 26.82c-14.83 0-26.818-11.988-26.818-26.82z"></path><path fill="#36c5f0" d="M94.07 53.638c-14.832 0-26.82-11.987-26.82-26.819S79.239 0 94.07 0s26.819 11.987 26.819 26.819v26.82zm0 13.613c14.832 0 26.819 11.987 26.819 26.819s-11.987 26.819-26.82 26.819H26.82C11.987 120.889 0 108.902 0 94.069c0-14.83 11.987-26.818 26.819-26.818z"></path><path fill="#2eb67d" d="M201.55 94.07c0-14.832 11.987-26.82 26.818-26.82s26.82 11.988 26.82 26.82s-11.988 26.819-26.82 26.819H201.55zm-13.41 0c0 14.832-11.988 26.819-26.82 26.819c-14.831 0-26.818-11.987-26.818-26.82V26.82C134.502 11.987 146.489 0 161.32 0s26.819 11.987 26.819 26.819z"></path><path fill="#ecb22e" d="M161.32 201.55c14.832 0 26.82 11.987 26.82 26.818s-11.988 26.82-26.82 26.82c-14.831 0-26.818-11.988-26.818-26.82V201.55zm0-13.41c-14.831 0-26.818-11.988-26.818-26.82c0-14.831 11.987-26.818 26.819-26.818h67.25c14.832 0 26.82 11.987 26.82 26.819s-11.988 26.819-26.82 26.819z"></path></svg>);
|
||||
}
|
||||
|
||||
export function DiscordIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (<svg xmlns="http://www.w3.org/2000/svg" width={256} height={199} viewBox="0 0 256 199" {...props}><path fill="#5865f2" d="M216.856 16.597A208.5 208.5 0 0 0 164.042 0c-2.275 4.113-4.933 9.645-6.766 14.046q-29.538-4.442-58.533 0c-1.832-4.4-4.55-9.933-6.846-14.046a207.8 207.8 0 0 0-52.855 16.638C5.618 67.147-3.443 116.4 1.087 164.956c22.169 16.555 43.653 26.612 64.775 33.193A161 161 0 0 0 79.735 175.3a136.4 136.4 0 0 1-21.846-10.632a109 109 0 0 0 5.356-4.237c42.122 19.702 87.89 19.702 129.51 0a132 132 0 0 0 5.355 4.237a136 136 0 0 1-21.886 10.653c4.006 8.02 8.638 15.67 13.873 22.848c21.142-6.58 42.646-16.637 64.815-33.213c5.316-56.288-9.08-105.09-38.056-148.36M85.474 135.095c-12.645 0-23.015-11.805-23.015-26.18s10.149-26.2 23.015-26.2s23.236 11.804 23.015 26.2c.02 14.375-10.148 26.18-23.015 26.18m85.051 0c-12.645 0-23.014-11.805-23.014-26.18s10.148-26.2 23.014-26.2c12.867 0 23.236 11.804 23.015 26.2c0 14.375-10.148 26.18-23.015 26.18"></path></svg>);
|
||||
}
|
||||
|
||||
export function TelegramIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (<svg xmlns="http://www.w3.org/2000/svg" width={256} height={256} viewBox="0 0 256 256" {...props}><defs><linearGradient id="SVGuySfwdaH" x1="50%" x2="50%" y1="0%" y2="100%"><stop offset="0%" stopColor="#2aabee"></stop><stop offset="100%" stopColor="#229ed9"></stop></linearGradient></defs><path fill="url(#SVGuySfwdaH)" d="M128 0C94.06 0 61.48 13.494 37.5 37.49A128.04 128.04 0 0 0 0 128c0 33.934 13.5 66.514 37.5 90.51C61.48 242.506 94.06 256 128 256s66.52-13.494 90.5-37.49c24-23.996 37.5-56.576 37.5-90.51s-13.5-66.514-37.5-90.51C194.52 13.494 161.94 0 128 0"></path><path fill="#fff" d="M57.94 126.648q55.98-24.384 74.64-32.152c35.56-14.786 42.94-17.354 47.76-17.441c1.06-.017 3.42.245 4.96 1.49c1.28 1.05 1.64 2.47 1.82 3.467c.16.996.38 3.266.2 5.038c-1.92 20.24-10.26 69.356-14.5 92.026c-1.78 9.592-5.32 12.808-8.74 13.122c-7.44.684-13.08-4.912-20.28-9.63c-11.26-7.386-17.62-11.982-28.56-19.188c-12.64-8.328-4.44-12.906 2.76-20.386c1.88-1.958 34.64-31.748 35.26-34.45c.08-.338.16-1.598-.6-2.262c-.74-.666-1.84-.438-2.64-.258c-1.14.256-19.12 12.152-54 35.686c-5.1 3.508-9.72 5.218-13.88 5.128c-4.56-.098-13.36-2.584-19.9-4.708c-8-2.606-14.38-3.984-13.82-8.41c.28-2.304 3.46-4.662 9.52-7.072"></path></svg>);
|
||||
}
|
||||
|
||||
|
||||
export function NtfyIcon(props: SVGProps<SVGSVGElement>) {
|
||||
|
||||
return ( <svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlSpace="preserve"
|
||||
id="Layer_1"
|
||||
x={0}
|
||||
y={0}
|
||||
viewBox="0 0 512 512"
|
||||
{...props}
|
||||
>
|
||||
<style>{".st1{fill:#fff}"}</style>
|
||||
<linearGradient
|
||||
id="SVGID_1_"
|
||||
x1={115.045}
|
||||
x2={142.93}
|
||||
y1={512.747}
|
||||
y2={491.741}
|
||||
gradientTransform="matrix(15.8584 0 0 -15.8584 -1816.246 8200.18)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop
|
||||
offset={0}
|
||||
style={{
|
||||
stopColor: "#348878",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={1}
|
||||
style={{
|
||||
stopColor: "#56bda8",
|
||||
}}
|
||||
/>
|
||||
</linearGradient>
|
||||
<path
|
||||
d="M441.8 64.8H84.7c-24.5 0-44.6 19-44.6 42.1l.3 285.3-6.7 38.4 95.6-28.8h312.4c24.5 0 44.6-19 44.6-42.1V106.9c.1-23.2-20-42.1-44.5-42.1"
|
||||
style={{
|
||||
fill: "url(#SVGID_1_)",
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="M78.1 32.7C40.9 32.7 9 62 9 99.2l.4 311.2-9.4 69 127.1-33.8H443c37.2 0 69.1-29.3 69.1-66.5V99.2c0-37.2-31.9-66.5-69-66.5zm0 46.9H443c13.1 0 22.1 9.5 22.1 19.6V379c0 10-9 19.8-22.1 19.6H120.2l-64.6 19.5.7-3.8-.4-315.1c0-10.1 9.1-19.6 22.2-19.6"
|
||||
className="st1"
|
||||
/>
|
||||
<path
|
||||
d="M116.4 310.9v-34.8l73.1-38.7c2.4-1.2 4.6-2.1 6.8-2.7 2.3-.8 4.5-1.4 6.3-1.8l6-.6V230q-3-.3-6-1.2-2.85-.6-6.3-1.5c-2.2-.8-4.5-1.8-6.8-3l-73.1-38.7v-35.1l113 61.5v37.4z"
|
||||
className="st1"
|
||||
/>
|
||||
<path
|
||||
d="M110.5 139.7v50.4l76.6 40.6.5.2-.5.2-76.6 40.6v50.1l8.2-4.5L235.1 254v-46.4zm11.8 21.7 101.2 55.1V245l-101.2 55.1v-19.4l69.6-36.8.1-.1c1.9-1 3.8-1.7 5.8-2.3h.1l.2-.1c2.1-.7 3.9-1.2 5.5-1.6l11.1-1.1v-15.2l-5.4-.5c-1.7-.2-3.5-.5-5.1-1.1h-.2l-.2-.1c-2-.4-3.9-.9-5.9-1.4-2-.7-4-1.6-5.9-2.6l-.1-.1-69.6-36.7zm152.3 137.4h120.7v32.6H274.6z"
|
||||
className="st1"
|
||||
/>
|
||||
<path
|
||||
d="M268.7 292.1v46h132.4v-46zm11.7 13.4h109v19.2h-109z"
|
||||
className="st1"
|
||||
/>
|
||||
</svg>)
|
||||
|
||||
}
|
||||
|
||||
|
||||
export function GotifyIcon(props: SVGProps<SVGSVGElement>) {
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlSpace="preserve"
|
||||
id="Layer_2"
|
||||
x={0}
|
||||
y={0}
|
||||
viewBox="46.6 113.46 377.2 307.81"
|
||||
{...props}
|
||||
>
|
||||
<style>
|
||||
{
|
||||
".st0{fill:#ddcba2}.st0,.st1,.st2,.st4,.st5{stroke:#000;stroke-width:2;stroke-linecap:round;stroke-miterlimit:10}.st1{fill:#71caee}.st2{fill:#fff}.st4{fill:#f0f0f0}.st5{fill:none}"
|
||||
}
|
||||
</style>
|
||||
<switch>
|
||||
<foreignObject
|
||||
width={1}
|
||||
height={1}
|
||||
x={0}
|
||||
y={0}
|
||||
requiredExtensions="http://ns.adobe.com/AdobeIllustrator/10.0/"
|
||||
/>
|
||||
<g>
|
||||
<path
|
||||
d="M317.7 376.2c6.2-1.7 15.8 0 19.5 3.2 5.5 4.8 4.9 20.9 1.1 29-.8 1.7-1.8 3.4-3.2 4.4s-3.4 1.2-4.7 0-1.7-3.3-1.9-5.2c-.2-3.1-.2-6.2.3-9.2.2-1.3.2-2.9-.2-4.2-.6-2.2-2.5-3.1-4.5-3.5-3.4-.8-14.3-.7-19.5-.7-.8 0 1.5-9.1 2-9.7 2.6-2.9 7.5-3.1 11.1-4.1z"
|
||||
className="st0"
|
||||
/>
|
||||
<path
|
||||
d="m258.9 119.7-9-2.7c-4.6-1.4-9.2-2.8-14-2.5-2.8.2-6.1 1.3-6.9 4-.6 2-1.6 7.3-1.3 7.9 1.5 3.4 13.9 6.7 18.3 6.7"
|
||||
className="st1"
|
||||
/>
|
||||
<path d="M392.6 177.9c-1.4 1.4-2.2 3.5-2.5 5.5-.2 1.4-.1 3 .5 4.3s1.8 2.3 3.1 3c1.3.6 2.8.9 4.3.9 1.1 0 2.3-.1 3.1-.9.6-.7.8-1.6.9-2.5.2-2.3-.1-4.7-.9-6.9-.4-1.1-.9-2.3-1.8-3.1-1.7-1.8-4.5-2.2-6.4-.5-.1 0-.2.1-.3.2" />
|
||||
<path
|
||||
d="M358.5 164.2c-1-1 0-2.7 1-3.7 5.8-5.2 15.1-4.6 21.8-.6 10.9 6.6 15.6 19.9 17.2 32.5.6 5.2.9 10.6-.5 15.7s-4.6 9.9-9.3 12.1c-1.1.5-2.3.9-3.4.5s-1.9-1.8-1.2-2.8c-9.4-13.6-19-26.8-20.9-43.2-.5-4.1-1.8-7.4-4.7-10.5z"
|
||||
className="st2"
|
||||
/>
|
||||
<path
|
||||
d="M134.7 328.4c-5.1-3.1-9.9-6.6-14.3-10.6-1.3-1.2-2.6-2.5-2.6-4.3 0-1.2.6-2.2 1.2-3.2.8-1.4 1.7-2.8 2.5-4.1 1.1-1.8 2.9-3.9 4.9-3.2.9.3 1.5 1.1 2 1.8 2.4 3.3 4.9 6.6 7.3 9.8 1.5 2 3.7 4.3 6.1 3.5"
|
||||
className="st1"
|
||||
/>
|
||||
<path
|
||||
d="M209.6 133c33.2-18 77.8-19.6 111.5-8.7 24.3 7.9 43.4 26.7 53.3 50 8.7 20.6 10.5 43.6 8.1 65.7-4.4 40.2-20.2 77.9-40.3 112.6-11.1 19-21.8 36-40.5 48.5-36.8 24.6-87.2 22.1-128.4 11.5-19.9-5.1-39.7-17.3-47.2-37.3-4.8-12.8-4.2-27.6 1.5-40 11.6-24.8 43.2-38.4 45.6-67.9.7-8.7-1.6-17.3-3.6-25.7-5.6-23.4-8.9-45.8 1.4-68.7 8.1-17.7 21.9-31 38.6-40z"
|
||||
className="st1"
|
||||
/>
|
||||
<path
|
||||
d="M189.8 151.4c-5.4-5.2-11.9-8.8-19-10.3-2.2-.5-4.7-.7-6.9.7-1.8 1.2-3.1 3.3-4.2 5.3-1.6 3-3 6.2-4.1 9.4-.4 1.2-.6 2.5 0 3.5.3.6.9.9 1.5 1.2 8.1 4.2 16.8 7.1 25.5 9.8"
|
||||
className="st1"
|
||||
/>
|
||||
<path
|
||||
d="M183.7 158.7c-2.5-1.8-16.8-12.1-18.7-4.8-.4 1.6.5 3.9 1.5 4.8"
|
||||
className="st1"
|
||||
/>
|
||||
<path
|
||||
d="M264.5 174.9c-.5.5-.9 1-1.3 1.6-9 11.6-12 27.9-9.3 42.1 1.7 9 5.9 17.9 13.2 23.4 19.3 14.6 51.5 13.5 68.4-1.5 24.4-21.7 13-67.6-14-78.8-17.6-7.2-43.7-1.6-57 13.2zm117.6 62.2c1.4-.1 2.9-.1 4.3.1.3 0 .7.1 1 .4.2.3.4.7.5 1.1 1 3.9.5 8.2.1 12.4-.1.9-.2 1.8-.6 2.6-1 2.1-3.1 2.7-4.7 2.7-.1 0-.2 0-.3-.1-.3-.2-.3-.7-.2-1.2.3-5.9-.1-11.9-.1-18q0 .15 0 0z"
|
||||
className="st2"
|
||||
/>
|
||||
<path
|
||||
d="M378.7 236.8c-1.4.4-2.5 2-2.8 4.4-.5 4.4-.7 8.9-.5 13.4 0 .9.1 1.9.5 2.4.2.3.5.4.8.4 1.6.3 4.1-.6 5.6-1 0 0 0-5.2-.1-8s-.1-6.1-.2-8.9v-2.2c.1-.7-2.6-.7-3.3-.5z"
|
||||
className="st2"
|
||||
/>
|
||||
<path
|
||||
d="M358.3 231.8c-.3 2.2.1 4.7 1.7 7.4 2.6 4.4 7 6.1 11.9 5.8 8.9-.6 25.3-5.4 27.5-15.7.6-3-.3-6.1-2.2-8.5-6.2-7.8-17.8-5.7-25.6-2-5.9 2.7-12.4 7-13.3 13z"
|
||||
className="st0"
|
||||
/>
|
||||
<path
|
||||
d="M386.4 208.6c2.2 1.4 3.7 3.8 4 7 .3 3.6-1.4 7.5-5 8.8-2.9 1.1-6.2.6-9.1-.4s-5.8-2.8-6.8-5.7c-.7-2-.3-4.3.7-6.1 1.1-1.8 2.8-3.2 4.7-4.1 3.9-1.8 8.4-1.6 11.5.5z"
|
||||
style={{
|
||||
fill: "#888e93",
|
||||
stroke: "#000",
|
||||
strokeWidth: 2,
|
||||
strokeLinecap: "round",
|
||||
strokeMiterlimit: 10,
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="M414.7 262.6c2.4.6 4.8 2.1 5.6 4.4s.1 4.9-1.6 6.7-4.2 2.5-6.6 2.5c-.8 0-1.7-.1-2.4-.5-2.5-1.1-3.5-4-4.2-6.6-1.8-6.8 3.6-7.8 9.2-6.5z"
|
||||
className="st0"
|
||||
/>
|
||||
<path
|
||||
d="M267.1 284.7c2.3-4.5 141.3-36.2 144.7-31.6 3.4 4.5 15.8 88.2 9 90.4-6.8 2.3-119.8 37.3-126.6 35s-29.4-89.3-27.1-93.8z"
|
||||
className="st4"
|
||||
/>
|
||||
<path
|
||||
d="M294.2 378.5s54.3-74.6 59.9-76.9c5.7-2.3 67.3 41.3 67.3 41.3"
|
||||
className="st5"
|
||||
/>
|
||||
<path
|
||||
d="M267 287.7s86 38.8 91.6 36.6c5.7-2.3 53.1-71.2 53.1-71.2"
|
||||
className="st4"
|
||||
/>
|
||||
<linearGradient
|
||||
id="SVGID_1_"
|
||||
x1={131.119}
|
||||
x2={103.259}
|
||||
y1={373.573}
|
||||
y2={396.951}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop
|
||||
offset={0}
|
||||
style={{
|
||||
stopColor: "#71caee",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.041}
|
||||
style={{
|
||||
stopColor: "#83cae2",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.121}
|
||||
style={{
|
||||
stopColor: "#9fcace",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.211}
|
||||
style={{
|
||||
stopColor: "#b6cbbe",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.314}
|
||||
style={{
|
||||
stopColor: "#c7cbb1",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.438}
|
||||
style={{
|
||||
stopColor: "#d4cba8",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.606}
|
||||
style={{
|
||||
stopColor: "#dbcba3",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={1}
|
||||
style={{
|
||||
stopColor: "#ddcba2",
|
||||
}}
|
||||
/>
|
||||
</linearGradient>
|
||||
<path
|
||||
d="M132.8 375.6c-3.5 3.8-7.3 7.8-13 9.2-4.6 1.2-10 .2-13.6-2.3-1.4-1-2.6-2.2-4-3.2-1.5-1-3.4-1.7-5.3-1.3-2.7.5-4.1 3.1-3.6 5.3 2 8.8 17 15.6 27.5 15.5 9 0 19-4.6 21.4-11.8"
|
||||
style={{
|
||||
fill: "url(#SVGID_1_)",
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="M132.8 375.6c-3.5 3.8-7.3 7.8-13 9.2-4.6 1.2-10 .2-13.6-2.3-1.4-1-2.6-2.2-4-3.2-1.5-1-3.4-1.7-5.3-1.3-2.7.5-4.1 3.1-3.6 5.3 2 8.8 17 15.6 27.5 15.5 9 0 19-4.6 21.4-11.8"
|
||||
className="st5"
|
||||
/>
|
||||
<linearGradient
|
||||
id="SVGID_2_"
|
||||
x1={265.033}
|
||||
x2={275.301}
|
||||
y1={280.295}
|
||||
y2={302.314}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop
|
||||
offset={0}
|
||||
style={{
|
||||
stopColor: "#71caee",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.041}
|
||||
style={{
|
||||
stopColor: "#83cae2",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.121}
|
||||
style={{
|
||||
stopColor: "#9fcace",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.211}
|
||||
style={{
|
||||
stopColor: "#b6cbbe",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.314}
|
||||
style={{
|
||||
stopColor: "#c7cbb1",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.438}
|
||||
style={{
|
||||
stopColor: "#d4cba8",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.606}
|
||||
style={{
|
||||
stopColor: "#dbcba3",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={1}
|
||||
style={{
|
||||
stopColor: "#ddcba2",
|
||||
}}
|
||||
/>
|
||||
</linearGradient>
|
||||
<path
|
||||
d="M261.9 283.5c-.1 4.2 4.3 7.3 8.4 7.6s8.2-1.3 12.2-2.6c1.4-.4 2.9-.8 4.2-.2 1.8.9 2.7 4.1 1.8 5.9s-3.4 3.5-5.3 4.4c-6.5 3-12.9 3.6-19.9 2-5.3-1.2-11.3-4.3-13-13.5"
|
||||
style={{
|
||||
fill: "url(#SVGID_2_)",
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="M261.9 283.5c-.1 4.2 4.3 7.3 8.4 7.6s8.2-1.3 12.2-2.6c1.4-.4 2.9-.8 4.2-.2 1.8.9 2.7 4.1 1.8 5.9s-3.4 3.5-5.3 4.4c-6.5 3-12.9 3.6-19.9 2-5.3-1.2-11.3-4.3-13-13.5"
|
||||
className="st5"
|
||||
/>
|
||||
<path d="M318.4 198.4c-2-.3-4.1.1-5.9 1.3-3.2 2.1-4.7 6.2-4.7 9.9 0 1.9.4 3.8 1.4 5.3 1.2 1.7 3.1 2.9 5.2 3.4 3.4.8 8.2.7 10.5-2.5 1-1.5 1.4-3.3 1.5-5.1.5-5.7-1.8-11.4-8-12.3" />
|
||||
<path
|
||||
d="M320.4 203.3c.9.3 1.7.8 2.1 1.7.4.8.4 1.7.3 2.5-.1 1-.6 2-1.5 2.7-.7.5-1.7.7-2.6.5s-1.7-.8-2.2-1.6c-1.1-1.6-.9-4.4.9-5.5.9-.4 2-.6 3-.3"
|
||||
style={{
|
||||
fill: "#fff",
|
||||
}}
|
||||
/>
|
||||
<linearGradient
|
||||
id="SVGID_3_"
|
||||
x1={-18.456}
|
||||
x2={110.382}
|
||||
y1={252.286}
|
||||
y2={252.286}
|
||||
gradientTransform="rotate(-5.297 -26.409 -856.73)scale(.99997)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop
|
||||
offset={0}
|
||||
style={{
|
||||
stopColor: "#333",
|
||||
stopOpacity: 0,
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.477}
|
||||
style={{
|
||||
stopColor: "#575756",
|
||||
stopOpacity: 0.2,
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.995}
|
||||
style={{
|
||||
stopColor: "#333",
|
||||
stopOpacity: 0.001911551,
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={1}
|
||||
style={{
|
||||
stopColor: "#333",
|
||||
stopOpacity: 0,
|
||||
}}
|
||||
/>
|
||||
</linearGradient>
|
||||
<path
|
||||
d="M81.6 222.4s56.5 68.4 123.9 42.4l6.6-29.7C150 272.2 82.8 214.3 82.8 214.3z"
|
||||
style={{
|
||||
fill: "url(#SVGID_3_)",
|
||||
}}
|
||||
/>
|
||||
<linearGradient
|
||||
id="SVGID_4_"
|
||||
x1={110.198}
|
||||
x2={283.737}
|
||||
y1={262.493}
|
||||
y2={262.493}
|
||||
gradientTransform="matrix(.9991 .04164 -.04164 .9991 -36.323 1.051)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop
|
||||
offset={0}
|
||||
style={{
|
||||
stopColor: "#333",
|
||||
stopOpacity: 0,
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.477}
|
||||
style={{
|
||||
stopColor: "#575756",
|
||||
stopOpacity: 0.2,
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.995}
|
||||
style={{
|
||||
stopColor: "#333",
|
||||
stopOpacity: 0.001911551,
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={1}
|
||||
style={{
|
||||
stopColor: "#333",
|
||||
stopOpacity: 0,
|
||||
}}
|
||||
/>
|
||||
</linearGradient>
|
||||
<path
|
||||
d="M64.2 234.9s63.1 101.5 157.8 78.9l14.2-38.5c-89.5 38.3-168.9-51-168.9-51z"
|
||||
style={{
|
||||
fill: "url(#SVGID_4_)",
|
||||
}}
|
||||
/>
|
||||
<linearGradient
|
||||
id="SVGID_5_"
|
||||
x1={218.967}
|
||||
x2={428.745}
|
||||
y1={263.594}
|
||||
y2={263.594}
|
||||
gradientTransform="rotate(9.265 -38.832 -826.002)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop
|
||||
offset={0}
|
||||
style={{
|
||||
stopColor: "#333",
|
||||
stopOpacity: 0,
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.477}
|
||||
style={{
|
||||
stopColor: "#575756",
|
||||
stopOpacity: 0.2,
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.995}
|
||||
style={{
|
||||
stopColor: "#333",
|
||||
stopOpacity: 0.001911551,
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={1}
|
||||
style={{
|
||||
stopColor: "#333",
|
||||
stopOpacity: 0,
|
||||
}}
|
||||
/>
|
||||
</linearGradient>
|
||||
<path
|
||||
d="M46.6 251.5s61.1 130.9 178 117.5l22.6-44.2c-113 33-195.3-85.7-195.3-85.7z"
|
||||
style={{
|
||||
fill: "url(#SVGID_5_)",
|
||||
}}
|
||||
/>
|
||||
</g>
|
||||
</switch>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function WebhookIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={2500}
|
||||
height={2448}
|
||||
fillRule="evenodd"
|
||||
strokeLinejoin="round"
|
||||
strokeMiterlimit={2}
|
||||
clipRule="evenodd"
|
||||
viewBox="-6.74 -6.74 223.55 218.799"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="#7547db"
|
||||
d="M111.134 71.906a21 21 0 0 1-1.324.042c-11.314 0-20.5-9.186-20.5-20.5s9.186-20.5 20.5-20.5 20.5 9.186 20.5 20.5a20.4 20.4 0 0 1-4.582 12.913l22.66 41.655a51 51 0 0 1 17.422-3.068c28.148 0 51 22.852 51 51s-22.852 51-51 51a51.2 51.2 0 0 1-28.036-8.373 7.303 7.303 0 0 1-1.359-11.105c.015-.016.031-.033.061-.036a7.665 7.665 0 0 1 9.736-1.228 36.06 36.06 0 0 0 19.598 5.792c19.897 0 36.05-16.153 36.05-36.05s-16.153-36.05-36.05-36.05c-6.89 0-16.669 3.014-22.548 6.745a2.637 2.637 0 0 1-3.899-.845zm-9.412 88.614a51 51 0 0 1-6.054 16.62c-14.074 24.378-45.29 32.743-69.667 18.669S-6.74 150.518 7.333 126.14a51.2 51.2 0 0 1 21.27-20.094 7.3 7.3 0 0 1 6.167-.211 7.3 7.3 0 0 1 4.13 4.587c.006.022.012.043 0 .07a7.665 7.665 0 0 1-3.804 9.047 36.05 36.05 0 0 0-14.816 14.076c-9.948 17.23-4.036 39.297 13.195 49.246 17.231 9.948 39.298 4.035 49.246-13.196 3.445-5.967 5.724-15.943 5.433-22.9a2.632 2.632 0 0 1 2.681-2.953l59.054 1.499q.292-.59.626-1.168c5.657-9.798 18.205-13.161 28.003-7.504 9.8 5.658 13.161 18.206 7.504 28.004s-18.205 13.16-28.004 7.503a20.4 20.4 0 0 1-8.892-10.424zM76.97 90.555A51 51 0 0 1 65.601 77c-14.074-24.377-5.71-55.594 18.667-69.667S139.862 1.624 153.935 26a51.2 51.2 0 0 1 6.768 28.466 7.306 7.306 0 0 1-8.938 6.73c-.022-.005-.044-.011-.062-.035a7.664 7.664 0 0 1-5.932-7.817 36.04 36.04 0 0 0-4.783-19.87c-9.948-17.23-32.014-23.143-49.245-13.195S68.599 52.294 78.548 69.525c3.445 5.967 10.945 12.929 17.115 16.155a2.635 2.635 0 0 1 1.218 3.799l-30.826 50.393q.366.548.7 1.125c5.656 9.799 2.294 22.347-7.505 28.004s-22.346 2.295-28.003-7.504-2.295-22.346 7.503-28.003a20.4 20.4 0 0 1 13.474-2.488z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -34,13 +34,11 @@ export const OrganizationAddMemberForm = ({onSuccessAction, users, organization}
|
||||
|
||||
const mutationAddMemberOrganisation = useMutation({
|
||||
mutationFn: async (data: AddMemberSchemaType) => {
|
||||
console.log(data);
|
||||
const result = await addMemberOrganizationAction({
|
||||
userId: data.userId,
|
||||
organizationId: organization.id,
|
||||
role: "member",
|
||||
});
|
||||
console.log(result);
|
||||
toast.success("Member successfully added!");
|
||||
router.refresh();
|
||||
onSuccessAction?.();
|
||||
@@ -71,7 +69,6 @@ export const OrganizationAddMemberForm = ({onSuccessAction, users, organization}
|
||||
placeholder="Enter a user email"
|
||||
entries={filteredUsers}
|
||||
onSelect={(entySelected: any) => {
|
||||
console.log("Form selection:", entySelected);
|
||||
field.onChange(entySelected.value);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -35,13 +35,11 @@ export const OrganizationDeleteMemberModal = ({ member, open, onOpenChangeAction
|
||||
},
|
||||
{
|
||||
onSuccess: async (response) => {
|
||||
console.log(response);
|
||||
toast.success("Member successfully deleted!");
|
||||
onOpenChangeAction(false);
|
||||
router.refresh();
|
||||
},
|
||||
onError: async (error) => {
|
||||
console.log(error);
|
||||
toast.error("An error occurred while deleting member!");
|
||||
onOpenChangeAction(false);
|
||||
},
|
||||
|
||||
@@ -49,7 +49,6 @@ export const OrganizationMemberChangeRoleModal = (props: OrganizationMemberChang
|
||||
router.refresh();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.log(error);
|
||||
toast.error("An error occurred while updating member");
|
||||
onOpenChangeAction(false);
|
||||
},
|
||||
|
||||
@@ -95,8 +95,6 @@ export const EmailForm = (props: EmailFormProps) => {
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4 mt-3"
|
||||
@@ -182,23 +180,6 @@ export const EmailForm = (props: EmailFormProps) => {
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-between gap-4">
|
||||
|
||||
{/*{props.defaultValues?.smtpFrom && (*/}
|
||||
{/* <ButtonWithLoading*/}
|
||||
{/* type="button"*/}
|
||||
{/* isPending={mutationSendEmailTest.isPending}*/}
|
||||
{/* onClick={async () => {*/}
|
||||
{/* await mutationSendEmailTest.mutateAsync();*/}
|
||||
{/* }}*/}
|
||||
{/* icon={<Send/>}*/}
|
||||
{/* size="default"*/}
|
||||
{/* className="bg-green-600 hover:bg-green-700 text-white font-medium shadow-sm transition-all"*/}
|
||||
|
||||
{/* >Send email test</ButtonWithLoading>*/}
|
||||
{/*)}*/}
|
||||
|
||||
|
||||
|
||||
{props.defaultValues?.smtpFrom && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -227,14 +208,13 @@ export const EmailForm = (props: EmailFormProps) => {
|
||||
)}
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
>Save</Button>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -28,11 +28,9 @@ export const StorageS3Form = (props: S3FormProps) => {
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: S3FormType) => {
|
||||
console.log(values);
|
||||
const updateS3Settings = await updateS3SettingsAction({ name: "system", data: values });
|
||||
const data = updateS3Settings?.data?.data;
|
||||
if (updateS3Settings?.serverError || !data) {
|
||||
console.log(updateS3Settings?.serverError);
|
||||
toast.error(updateS3Settings?.serverError);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Organization } from "@/db/schema/03_organization";
|
||||
import {AdminUserForm} from "@/components/wrappers/dashboard/admin/users/admin-user-form";
|
||||
|
||||
type AdminUserAddModalProps = {
|
||||
organizations: Organization[];
|
||||
};
|
||||
|
||||
export const AdminUserAddModal = ({ organizations }: AdminUserAddModalProps) => {
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus /> Create a user
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a new user</DialogTitle>
|
||||
<DialogDescription>To create a new user please provide following informations</DialogDescription>
|
||||
<AdminUserForm organizations={organizations} onSuccess={() => setOpen(false)} />
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
"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 {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { requestPasswordReset } from "@/lib/auth/auth-client";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type AdminUserChangePasswordProps = {
|
||||
open: boolean;
|
||||
user: User;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const AdminUserChangePassword = ({ user, open, onOpenChange }: AdminUserChangePasswordProps) => {
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await requestPasswordReset(
|
||||
{
|
||||
email: user.email,
|
||||
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Reset password request successfully sent!");
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.error.message);
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Change {user.name}'s password</AlertDialogTitle>
|
||||
<AlertDialogDescription>This action will send an email to the user to reset their password.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<ButtonWithLoading onClick={async () => await mutation.mutateAsync()} isPending={mutation.isPending}>
|
||||
Validate
|
||||
</ButtonWithLoading>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { authClient } from "@/lib/auth/auth-client";
|
||||
import { User } from "@/db/schema/02_user";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
|
||||
|
||||
type AdminUserChangeRoleModalProps = {
|
||||
open: boolean;
|
||||
user: User;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const AdminUserChangeRoleModal = (props: AdminUserChangeRoleModalProps) => {
|
||||
|
||||
|
||||
const { user, open, onOpenChange } = props;
|
||||
const router = useRouter();
|
||||
const [role, setRole] = useState<string | null>(user.role);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await authClient.admin.setRole(
|
||||
{
|
||||
userId: user.id,
|
||||
// @ts-ignore
|
||||
role: role,
|
||||
},
|
||||
{
|
||||
onSuccess: async (response) => {
|
||||
toast.success("User role changed successfully.");
|
||||
onOpenChange(false);
|
||||
router.refresh();
|
||||
},
|
||||
onError: async (error) => {
|
||||
console.error("AdminUserChangeRoleModal - setRole", error);
|
||||
toast.error("An error occurred while updating user roles.");
|
||||
onOpenChange(false);
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Change the user's role</DialogTitle>
|
||||
<DialogDescription>Modify this user's role within your organization.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Select defaultValue={user.role ?? ""} onValueChange={setRole}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Sélectionnez un rôle" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<DialogFooter>
|
||||
<div className="flex gap-4 justify-end">
|
||||
<ButtonWithLoading
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</ButtonWithLoading>
|
||||
<ButtonWithLoading
|
||||
isPending={mutation.isPending}
|
||||
onClick={async () => {
|
||||
await mutation.mutateAsync();
|
||||
}}
|
||||
>
|
||||
Validate
|
||||
</ButtonWithLoading>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
"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 {
|
||||
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";
|
||||
|
||||
type AdminDeleteUserModalProps = {
|
||||
open: boolean;
|
||||
user: User;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {UserEditSchema, UserEditType, UserSchema} from "@/components/wrappers/dashboard/admin/users/user.schema";
|
||||
import {updateUserAction} from "@/components/wrappers/dashboard/admin/users/user.action";
|
||||
type AdminUserEditFormProps = {
|
||||
onSuccess?: () => void;
|
||||
defaultValues: {
|
||||
id: string;
|
||||
} & UserEditType;
|
||||
};
|
||||
|
||||
export const AdminUserEditForm = ({ onSuccess, defaultValues }: AdminUserEditFormProps) => {
|
||||
|
||||
|
||||
const router = useRouter();
|
||||
const form = useZodForm({
|
||||
schema: defaultValues ? UserEditSchema : UserSchema,
|
||||
defaultValues: {
|
||||
name: defaultValues.name,
|
||||
email: defaultValues.email,
|
||||
},
|
||||
});
|
||||
|
||||
const onCancel = () => {
|
||||
form.reset();
|
||||
onSuccess?.();
|
||||
};
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: UserEditType) => {
|
||||
const result = await updateUserAction({
|
||||
...data,
|
||||
id: defaultValues?.id || "",
|
||||
});
|
||||
const inner = result?.data;
|
||||
if (inner?.success) {
|
||||
toast.success("User Successfully updated");
|
||||
onSuccess?.();
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error("An error occurred");
|
||||
onSuccess?.();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={async (values) => {
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter a name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input disabled placeholder="Fill user email" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-4 justify-end">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<ButtonWithLoading type="submit" isPending={mutation.isPending}>Validate</ButtonWithLoading>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import {User} from "@/db/schema/02_user";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from "@/components/ui/dialog";
|
||||
import {AdminUserEditForm} from "./admin-user-edit-form";
|
||||
|
||||
type AdminUserEditPasswordProps = {
|
||||
open: boolean;
|
||||
user: User;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const AdminUserEdit = ({user, open, onOpenChange}: AdminUserEditPasswordProps) => {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit ${user.name}'s profile</DialogTitle>
|
||||
<DialogDescription>Update following information</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<AdminUserEditForm
|
||||
defaultValues={{
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
id: user.id,
|
||||
}}
|
||||
onSuccess={() => onOpenChange(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import {useRouter} from "next/navigation";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
|
||||
import {Organization} from "@/db/schema/03_organization";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {UserSchema, UserType} from "@/components/wrappers/dashboard/admin/users/user.schema";
|
||||
import {toast} from "sonner";
|
||||
import {createUserAction} from "@/components/wrappers/dashboard/admin/users/user.action";
|
||||
|
||||
type AdminUserFormProps = {
|
||||
onSuccess?: () => void;
|
||||
organizations: Organization[];
|
||||
};
|
||||
|
||||
export const AdminUserForm = ({onSuccess, organizations}: AdminUserFormProps) => {
|
||||
|
||||
|
||||
const router = useRouter();
|
||||
const form = useZodForm({
|
||||
schema: UserSchema,
|
||||
});
|
||||
|
||||
|
||||
const onCancel = () => {
|
||||
form.reset();
|
||||
onSuccess?.();
|
||||
};
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: UserType) => {
|
||||
const result = await createUserAction(data);
|
||||
const inner = result?.data;
|
||||
if (inner?.success) {
|
||||
toast.success("User Successfully created");
|
||||
onSuccess?.();
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error("An error occurred");
|
||||
onSuccess?.();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={async (values) => {
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter a name" {...field} value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Fill user email" {...field} value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
|
||||
<div className="flex gap-4 justify-end">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<ButtonWithLoading type="submit" isPending={mutation.isPending}>
|
||||
Validate
|
||||
</ButtonWithLoading>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
import { DataTable } from "@/components/wrappers/common/table/data-table";
|
||||
import {usersListColumns} from "@/components/wrappers/dashboard/admin/users/table-colums";
|
||||
import {User} from "@/db/schema/02_user";
|
||||
|
||||
type AdminUserListProps = {
|
||||
users: User[];
|
||||
};
|
||||
|
||||
export const AdminUserList = ({ users }: AdminUserListProps) => {
|
||||
return <DataTable columns={usersListColumns()} data={users} enablePagination={true} enableSelect={false} />;
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import {ColumnDef} from "@tanstack/react-table";
|
||||
import {User} from "@/db/schema/02_user";
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
|
||||
import {formatLocalizedDate, timeAgo} from "@/utils/date-formatting";
|
||||
import {Tooltip, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
|
||||
import {TooltipContent} from "@radix-ui/react-tooltip";
|
||||
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>[] {
|
||||
|
||||
return [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Profile",
|
||||
cell: ({row}) => {
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="flex flex-row items-center gap-x-2">
|
||||
<Avatar>
|
||||
<AvatarImage src={row.original.image ?? ""} alt={row.original.name}/>
|
||||
<AvatarFallback>
|
||||
{row.original.name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-row items-center gap-x-2">
|
||||
<div className="font-medium">{row.original.name}</div>
|
||||
<Info size={16} aria-hidden="true" className="text-muted-foreground"/>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="bg-background shadow-lg border border-border z-20 rounded-md"
|
||||
side="bottom">
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>Joined</TableCell>
|
||||
<TableCell
|
||||
className="text-right text-muted-foreground">{formatLocalizedDate(row.original.createdAt)}</TableCell>
|
||||
</TableRow>
|
||||
{/*{row.original.lastConnectedAt && (*/}
|
||||
{/* <TableRow>*/}
|
||||
{/* <TableCell>Last connected</TableCell>*/}
|
||||
{/* <TableCell className="text-right text-muted-foreground">*/}
|
||||
{/* {formatLocalizedDate(row.original.lastConnectedAt)} ({timeAgo(row.original.lastConnectedAt, locale)})*/}
|
||||
{/* </TableCell>*/}
|
||||
{/* </TableRow>*/}
|
||||
{/*)}*/}
|
||||
{row.original.lastChangedPasswordAt && (
|
||||
<TableRow>
|
||||
<TableCell>Last password change</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground">
|
||||
{formatLocalizedDate(row.original.lastChangedPasswordAt)} ({timeAgo(row.original.lastChangedPasswordAt)})
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "email",
|
||||
header: "Email",
|
||||
},
|
||||
{
|
||||
accessorKey: "role",
|
||||
header: "Role",
|
||||
cell: ({row}) => {
|
||||
const role = row.original.role!;
|
||||
return <Badge>{role}</Badge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
cell: ({row}) => <UserActionsCell user={row.original}/>,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import {useState} from "react";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {MoreHorizontal, Settings, Trash2, RotateCcwKey, UserCog} from "lucide-react";
|
||||
import {authClient} from "@/lib/auth/auth-client";
|
||||
import {cn} from "@/lib/utils";
|
||||
import {User} from "@/db/schema/02_user";
|
||||
import {AdminUserChangePassword} from "./admin-user-change-password-modal";
|
||||
import {AdminUserEdit} from "./admin-user-edit-modal";
|
||||
import {AdminUserChangeRoleModal} from "@/components/wrappers/dashboard/admin/users/admin-user-change-role-modal";
|
||||
import {AdminDeleteUserModal} from "@/components/wrappers/dashboard/admin/users/admin-user-delete-modal";
|
||||
|
||||
interface UserActionsCellProps {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export function UserActionsCell({user}: UserActionsCellProps) {
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isModalChangePasswordOpen, setIsModalChangePasswordOpen] = useState(false);
|
||||
const [isModalEditUserOpen, setIsModalEditUserOpen] = useState(false);
|
||||
const [isModalDeleteOpen, setIsModalDeleteOpen] = useState(false);
|
||||
const {data: session, isPending, error} = authClient.useSession();
|
||||
if (isPending || error) return null;
|
||||
const isCurrentUser = session?.user?.id === user.id;
|
||||
const isSuperAdmin = session?.user?.role === "superadmin";
|
||||
|
||||
if (isCurrentUser || user.role === "superadmin") return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminUserChangeRoleModal user={user} open={isModalOpen} onOpenChange={setIsModalOpen}/>
|
||||
<AdminDeleteUserModal user={user} open={isModalDeleteOpen} onOpenChange={setIsModalDeleteOpen}/>
|
||||
<AdminUserChangePassword user={user} open={isModalChangePasswordOpen}
|
||||
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>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="w-4 h-4"/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => setIsModalOpen(true)}>
|
||||
<Settings className="w-4 h-4 mr-2"/>
|
||||
Role
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setIsModalEditUserOpen(true)}>
|
||||
<UserCog className="w-4 h-4 mr-2"/>
|
||||
Edit User
|
||||
</DropdownMenuItem>
|
||||
{isSuperAdmin && (
|
||||
<>
|
||||
<DropdownMenuSeparator/>
|
||||
<DropdownMenuItem onSelect={() => setIsModalDeleteOpen(true)} className="text-red-600">
|
||||
<Trash2 className="w-4 h-4 mr-2"/>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
"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 {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/email-helper";
|
||||
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";
|
||||
|
||||
export const createUserAction = userAction.schema(UserSchema).action(async ({parsedInput}): Promise<ServerActionResult<User>> => {
|
||||
try {
|
||||
const password = generateValidPassword();
|
||||
|
||||
const userData: SignUpUser = {
|
||||
name: parsedInput.name || extractNameFromEmail(parsedInput.email),
|
||||
email: parsedInput.email,
|
||||
password: password,
|
||||
theme: "dark",
|
||||
role: "user",
|
||||
};
|
||||
|
||||
const newUser = await createUserDb(userData);
|
||||
|
||||
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"),
|
||||
});
|
||||
|
||||
if (defaultOrganization) {
|
||||
await auth.api.addMember({
|
||||
body: {
|
||||
userId: newUser.id,
|
||||
organizationId: defaultOrganization.id,
|
||||
role: "admin",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
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",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const updateUserAction = userAction
|
||||
.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",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
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",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from "zod";
|
||||
import { zEmail, zString } from "@/lib/zod";
|
||||
|
||||
export const UserSchema = z.object({
|
||||
email: zEmail(),
|
||||
name: zString(),
|
||||
});
|
||||
|
||||
export type UserType = z.infer<typeof UserSchema>;
|
||||
|
||||
export const UserEditSchema = z.object({
|
||||
email: zEmail(),
|
||||
name: zString(),
|
||||
});
|
||||
|
||||
export type UserEditType = z.infer<typeof UserEditSchema>;
|
||||
@@ -1,11 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import {Card, CardContent, CardHeader} from "@/components/ui/card";
|
||||
import {useState} from "react";
|
||||
import Link from "next/link";
|
||||
import {formatDateLastContact} from "@/utils/date-formatting";
|
||||
import {Card} from "@/components/ui/card";
|
||||
import {ConnectionCircle} from "@/components/wrappers/common/connection-circle";
|
||||
import {Agent, AgentWith} from "@/db/schema/08_agent";
|
||||
import {Activity, Database, ShieldCheck} from "lucide-react";
|
||||
import {formatDateLastContact} from "@/utils/date-formatting";
|
||||
import {AgentWith} from "@/db/schema/08_agent";
|
||||
import {Activity, ChevronRight, Copy, Check, Fingerprint, Server, Database, ShieldCheck} from "lucide-react";
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
import {truncateWords} from "@/utils/text";
|
||||
import {useIsMobile} from "@/hooks/use-mobile";
|
||||
|
||||
export type agentCardProps = {
|
||||
data: AgentWith;
|
||||
@@ -13,39 +17,79 @@ export type agentCardProps = {
|
||||
|
||||
export const AgentCard = (props: agentCardProps) => {
|
||||
const {data: agent} = props;
|
||||
const isMobile = useIsMobile();
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
const handleCopy = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(agent.id);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Link href={`/dashboard/agents/${agent.id}`}
|
||||
className="block transition-all duration-200 hover:scale-[1.01] hover:shadow-md rounded-xl"
|
||||
<Link
|
||||
href={`/dashboard/agents/${agent.id}`}
|
||||
className="group block transition-all duration-200 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl"
|
||||
>
|
||||
<Card className="flex flex-row justify-between">
|
||||
<div className="flex-1 text-left">
|
||||
<CardHeader className="text-2xl font-bold">{agent.name}</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4"/>
|
||||
<span>{formatDateLastContact(agent.lastContact)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4"/>
|
||||
<span>{agent.databases?.length ?? 0} DB</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-4 w-4"/>
|
||||
{agent.version ?
|
||||
<span>v{agent.version}</span>
|
||||
:
|
||||
<span>N/A</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<Card className="flex flex-row items-center p-4 gap-5 transition-all border-border/50 bg-card hover:bg-accent/50 hover:border-primary/50 group-hover:shadow-md overflow-hidden">
|
||||
<div className="relative w-12 h-12 p-2.5 bg-background rounded-xl border border-border/50 shadow-sm flex items-center justify-center group-hover:border-primary/30 transition-all duration-300 group-hover:scale-105 shrink-0">
|
||||
<Server className="w-6 h-6 text-foreground/80 group-hover:text-primary transition-colors" />
|
||||
</div>
|
||||
<div className="flex items-center px-4">
|
||||
<ConnectionCircle date={agent.lastContact}/>
|
||||
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-lg font-black text-foreground group-hover:text-primary transition-colors truncate tracking-tight ">
|
||||
{isMobile ? truncateWords(agent.name, 2) : agent.name}
|
||||
</h3>
|
||||
{agent.version && (
|
||||
<Badge variant="secondary" className="h-5 px-1.5 text-[10px] font-bold tracking-wider">
|
||||
v{agent.version}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
<div className="hidden sm:flex items-center gap-1.5 min-w-0">
|
||||
<Fingerprint className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="font-mono text-xs font-bold truncate opacity-80">
|
||||
{agent.id}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="ml-0.5 p-1 hover:bg-muted rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 z-10"
|
||||
title="Copy ID"
|
||||
>
|
||||
{isCopied ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<Database className="w-3.5 h-3.5" />
|
||||
<span className="text-xs font-bold uppercase tracking-tight opacity-80">
|
||||
{agent.databases?.length ?? 0} DBs
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 shrink-0">
|
||||
<div className="hidden sm:flex flex-col items-end gap-1 text-right">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/70">Last Contact</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Activity className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-bold uppercase tracking-tight">
|
||||
{formatDateLastContact(agent.lastContact)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="scale-110">
|
||||
<ConnectionCircle date={agent.lastContact}/>
|
||||
</div>
|
||||
|
||||
<ChevronRight className="w-5 h-5 text-muted-foreground group-hover:text-primary group-hover:translate-x-1 transition-all" />
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client"
|
||||
|
||||
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
|
||||
import {Server} from "lucide-react";
|
||||
import {formatDateLastContact} from "@/utils/date-formatting";
|
||||
import {AgentCardKey} from "@/components/wrappers/dashboard/agent/agent-card-key/agent-card-key";
|
||||
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
|
||||
import {AgentDatabaseCard} from "@/components/wrappers/dashboard/agent/agent-database-card";
|
||||
import {AgentWithDatabases} from "@/db/schema/08_agent";
|
||||
import {eventUpdate} from "@/types/events";
|
||||
import {useAutoRefresh} from "@/hooks/use-auto-refresh";
|
||||
|
||||
type AgentContentPageProps = {
|
||||
edgeKey: string;
|
||||
agent: AgentWithDatabases
|
||||
|
||||
}
|
||||
|
||||
export const AgentContentPage = ({edgeKey, agent}: AgentContentPageProps) => {
|
||||
|
||||
useAutoRefresh({
|
||||
poll: {
|
||||
enabled: true,
|
||||
intervalMs: 5000,
|
||||
},
|
||||
sse: {
|
||||
enabled: true,
|
||||
url: "/api/events",
|
||||
eventName: "modification",
|
||||
shouldRefresh: (data) => {
|
||||
const update = data as eventUpdate;
|
||||
return Boolean(update?.update);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between gap-6 ">
|
||||
<Card className="w-full sm:w-auto flex-1">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Databases</CardTitle>
|
||||
<Server className="h-4 w-4 text-muted-foreground"/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{agent.databases.length}</div>
|
||||
<p className="text-xs text-muted-foreground">Databases linked to this agent</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="w-full sm:w-auto flex-1">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Last contact</CardTitle>
|
||||
<Server className="h-4 w-4 text-muted-foreground"/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{formatDateLastContact(agent.lastContact)}</div>
|
||||
<p className="text-xs text-muted-foreground">Last contact with agent</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
<Card className="w-full sm:w-auto flex-1 ">
|
||||
<CardHeader className="font-bold text-xl">
|
||||
Edge Key
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AgentCardKey
|
||||
edgeKey={edgeKey}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<CardsWithPagination cardsPerPage={4} numberOfColumns={2} data={agent.databases}
|
||||
cardItem={AgentDatabaseCard}/>
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import {Database} from "@/db/schema/07_database";
|
||||
import {DatabaseCard} from "@/components/wrappers/dashboard/projects/project-card/project-database-card";
|
||||
|
||||
export type agentDatabaseCardProps = {
|
||||
data: Database;
|
||||
};
|
||||
|
||||
export const AgentDatabaseCard = (props: agentDatabaseCardProps) => {
|
||||
const {data: database} = props;
|
||||
|
||||
return <DatabaseCard withDetails={false} data={database}/>;
|
||||
|
||||
//Todo: Add again when impersonation system will be implemented
|
||||
/* if (!database.projectId) {
|
||||
return <DatabaseCard data={database}/>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link className="group block transition-all duration-200 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl"
|
||||
href={`/dashboard/projects/${database.projectId}/database/${database.id}`}>
|
||||
<DatabaseCard data={database}/>
|
||||
</Link>
|
||||
);*/
|
||||
};
|
||||
@@ -47,7 +47,6 @@ export const AgentForm = (props: agentFormProps) => {
|
||||
|
||||
const data = createAgent?.data?.data;
|
||||
if (createAgent?.serverError || !data) {
|
||||
console.log(createAgent?.serverError);
|
||||
toast.error(createAgent?.serverError);
|
||||
return;
|
||||
}
|
||||
@@ -58,54 +57,53 @@ export const AgentForm = (props: agentFormProps) => {
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4 mt-3"
|
||||
onSubmit={async (values) => {
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
defaultValue=""
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Agent 1" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Your agent project name</FormDescription>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
defaultValue=""
|
||||
name="description"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="This agent is for the client exemple.com" {...field}
|
||||
value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormDescription>Enter your project agent description</FormDescription>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button>{isCreate ? `Create agent` : `Save agent`}</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<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="Agent 1" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Your agent project name</FormDescription>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
defaultValue=""
|
||||
name="description"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="This agent is for the client exemple.com" {...field}
|
||||
value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormDescription>Enter your project agent description</FormDescription>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit">
|
||||
{isCreate ? "Create" : "Update"}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||