Compare commits

...
77 Commits
Author SHA1 Message Date
Théo LAGACHE bf81f437d1 chore(release): 1.1.10 2026-01-07 15:29:53 +01:00
Théo LAGACHEandGitHub 81a4d33df1 Merge pull request #7 from Portabase/feat/webhooks
Feat/webhooks
2026-01-07 15:29:30 +01:00
Théo LAGACHE baab364b56 chore(release): 1.1.10-rc.2 2026-01-07 15:00:21 +01:00
Théo LAGACHE 585ee041ec ui fixes 2026-01-07 15:00:11 +01:00
Théo LAGACHE da3d4350cc chore(release): 1.1.10-rc.1 2026-01-07 13:33:42 +01:00
Théo LAGACHE df9390847f add: new notifiers and ui fixes 2026-01-07 13:33:23 +01:00
Théo LAGACHE 2a1acbfde8 chore(release): 1.1.9 2026-01-07 09:50:42 +01:00
Théo LAGACHEandGitHub 16ef8507b0 Merge pull request #6 from Portabase/readme
Readme
2026-01-07 09:48:56 +01:00
Théo LAGACHE df5d4e336c update: release 2026-01-07 09:46:30 +01:00
Théo LAGACHE ad2015329b chore(release): 1.1.9-rc6 2026-01-06 16:35:47 +01:00
Théo LAGACHE 4cc4bed785 chore(release): 1.1.9-rc5 2026-01-06 16:35:44 +01:00
Théo LAGACHE 6c506e6907 chore(release): 1.1.9-rc5 2026-01-06 16:25:12 +01:00
Théo LAGACHE 977f9344c7 chore(release): 1.1.9-rc4 2026-01-06 16:23:09 +01:00
Théo LAGACHE 2adb2f3b68 chore(release): 1.1.9-rc3 2026-01-06 16:21:06 +01:00
Théo LAGACHE 40a6e9e69f fix 2026-01-06 16:12:04 +01:00
Théo LAGACHE e1d1a3fb9a fix(ci): grant necessary permissions for docker publish workflow 2026-01-06 15:37:19 +01:00
Théo LAGACHE 75120c0a56 fix 2026-01-06 15:33:06 +01:00
Théo LAGACHE 33f1ef3bb3 ci: restore release candidate workflow and use reusable actions 2026-01-06 15:10:28 +01:00
Théo LAGACHE b6e742333c update: README.md 2026-01-06 13:26:52 +01:00
Théo LAGACHE 9f8c66fc20 update readme.md 2026-01-06 12:11:45 +01:00
charlesgauthereau 3b93257890 [Release] - 1.1.8 2026-01-05 19:41:53 +01:00
charlesgauthereau 1104e2a074 [FIX] (profile) - error in local upload (#4) 2026-01-05 19:39:59 +01:00
Théo LAGACHE 3e950652e8 update readme.md 2026-01-05 15:04:28 +01:00
charlesgauthereau 98e40466a0 [FIX] (agent) - order by createdAt 2026-01-04 21:24:06 +01:00
charlesgauthereau 324d80a735 [FIX] (backup) - change in extension file 2026-01-04 19:40:08 +01:00
charlesgauthereau 6770be63b6 [FIX] - adding fileSize in upload-backup.action.ts 2026-01-03 01:06:13 +01:00
charlesgauthereau 89aa33ebd8 Updating version. 2026-01-02 15:40:20 +01:00
charlesgauthereau 619c32f09d [FEATURE] - Adding fileSize in backup model in order to display it. 2026-01-02 15:32:36 +01:00
Charles GTEandGitHub c471423775 Update README.md 2026-01-02 15:06:24 +01:00
charlesgauthereau c589b9b8b8 Updating version. Ready for 1.1.7-rc.1 2026-01-02 14:24:37 +01:00
charlesgauthereau bae9ba0973 Increase proxyClientMaxBodySize to 10g in order to accept large databases. (#2) 2026-01-02 13:55:53 +01:00
charlesgauthereau 7bfc5c1d9b Increase proxyClientMaxBodySize to 10g in order to accept large databases. 2026-01-02 13:51:57 +01:00
charlesgauthereau 8bd3b0d731 Updating README.md 2026-01-01 19:10:20 +01:00
charlesgauthereau 06cd4de141 Moving to Github folder CONTRIBUTING.md 2026-01-01 19:08:40 +01:00
charlesgauthereau 33eb2df023 Adding CITATION.cff 2026-01-01 19:07:23 +01:00
charlesgauthereau 540ecd4b60 Updating README.md 2026-01-01 18:54:06 +01:00
charlesgauthereau 237418f009 Updating README.md 2026-01-01 18:49:18 +01:00
charlesgauthereau 4447180e9e Refactoring charts in statistics in organization. 2025-12-30 16:26:28 +01:00
charlesgauthereau f111946592 Refactoring charts in statistics in organization. 2025-12-30 16:22:34 +01:00
charlesgauthereau 475e404fae Bug fixes and refactoring user dropdown. 2025-12-30 14:02:49 +01:00
charlesgauthereau 07ce5215d4 Changing Github url in stars component. 2025-12-30 13:46:00 +01:00
charlesgauthereau 9f4895fd63 Refactoring user Page in admin panel. 2025-12-30 13:42:23 +01:00
charlesgauthereau c18a9da0d3 Refactoring user Page in admin panel. 2025-12-30 13:38:01 +01:00
charlesgauthereau 8e3eb32631 Adding support light and dark in email layout, by changing the Portabase logo. 2025-12-30 11:07:45 +01:00
charlesgauthereau f0598b3f59 Adding support for github provider. 2025-12-30 10:42:51 +01:00
charlesgauthereau 011120fdf9 Patching bug on portabase.config.ts about social providers. 2025-12-30 10:27:47 +01:00
charlesgauthereau ff175e1edd Updating security.yml 2025-12-29 15:59:58 +01:00
Soluce TechnologiesandGitHub 3f56008c39 Update README.md, Updating the Gif 2025-12-29 15:51:12 +01:00
charlesgauthereau 5df95bf7c1 Updating the README.md 2025-12-29 15:42:25 +01:00
charlesgauthereau 23d52d0447 Updating the README.md 2025-12-26 18:58:08 +01:00
charlesgauthereau 795a127998 Ready for 1.1.6-rc.1 2025-12-26 16:09:11 +01:00
charlesgauthereau fd73d383f9 Adding uploading backup feature. 2025-12-26 16:08:36 +01:00
charlesgauthereau ee285df7b5 Adding useAutoRefresh on agent page. 2025-12-26 11:55:43 +01:00
charlesgauthereau 7f7f8e642f README.md 2025-12-24 09:26:49 +01:00
charlesgauthereau d9997ff5e9 Changing assets with new DA. 2025-12-23 22:10:00 +01:00
charlesgauthereau 36adcb323a Changing assets with new DA. 2025-12-23 22:06:14 +01:00
charlesgauthereau 5302060f44 Working and testing the profile module. 2025-12-23 21:44:52 +01:00
charlesgauthereau d19a46bee6 Working and testing the profile module. 2025-12-23 18:25:31 +01:00
charlesgauthereau d504866aad Patching hydratation error due to theme. 2025-12-23 09:06:06 +01:00
charlesgauthereau 601dae4909 Working on the profile module. 2025-12-22 20:32:18 +01:00
charlesgauthereau 08f2ad0521 Refactoring on image and component loading. 2025-12-22 14:23:52 +01:00
charlesgauthereau 3b356e33e7 Adding AuthCard for mobile first design and refactoring the Logo loading in the layout. 2025-12-22 12:50:40 +01:00
charlesgauthereau 57d9b8da1e Bug in profile. 2025-12-21 20:17:05 +01:00
charlesgauthereau 86f50e5741 Bug in profile. 2025-12-21 19:41:30 +01:00
charlesgauthereau a6e52fd03d Ready for 1.1.5-rc.4 2025-12-21 19:27:00 +01:00
charlesgauthereau 116229a29e Start working on the profile refactoring. 2025-12-21 16:55:12 +01:00
charlesgauthereau 428782e8a4 Ready for 1.1.5-rc.2. Patching an api bug. 2025-12-21 11:57:07 +01:00
charlesgauthereau b8442803d9 Ready for 1.1.5-rc.1 2025-12-21 11:25:33 +01:00
charlesgauthereau 82ab2c63d6 File refactoring and react errors. 2025-12-21 09:59:49 +01:00
charlesgauthereau e953de0e06 For release 1.1.4 in main 2025-12-09 21:31:46 +01:00
Soluce TechnologiesandGitHub f6d927f866 Merge pull request #1 from Soluce-Technologies/dependabot/npm_and_yarn/next-16.0.7
Bump next from 15.5.2 to 16.0.7
2025-12-09 21:29:58 +01:00
dependabot[bot]andGitHub 3773c4b068 Bump next from 15.5.2 to 16.0.7
Bumps [next](https://github.com/vercel/next.js) from 15.5.2 to 16.0.7.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v15.5.2...v16.0.7)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.0.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-09 20:28:45 +00:00
charlesgauthereau bb0dbdadae For release 1.1.4 in main 2025-12-09 21:25:29 +01:00
charlesgauthereau dd8aec50eb For release 1.1.4 in main 2025-12-09 21:23:15 +01:00
charlesgauthereau 7410aaea38 Hiding notifiers if not admin or owner in organization. 2025-12-08 09:57:17 +01:00
charlesgauthereau 0f9bbbda3c Ready for release 1.1.4 2025-12-07 12:25:10 +01:00
charlesgauthereau 25f9bc6e79 Ready for release 1.1.4 2025-12-07 12:11:00 +01:00
232 changed files with 37850 additions and 16750 deletions
+1 -1
View File
@@ -34,4 +34,4 @@ S3_USE_SSL=true
STORAGE_TYPE=local
# Retention
RETENTION_CRON="* * * * *"
RETENTION_CRON="* * * * *"
+1 -1
View File
@@ -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**
+3 -3
View File
@@ -7,9 +7,9 @@
We take security seriously and aim to support the following versions of the project with security updates:
| Version | Supported |
|-----------|--------------------|
| 1.x | ✅ Fully Supported |
| Version | Supported |
|---------|--------------------|
| Latest | ✅ Fully Supported |
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+63
View File
@@ -0,0 +1,63 @@
name: Docker Publish
on:
workflow_call:
inputs:
image_name:
required: false
type: string
default: 'solucetechnologies/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
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: Log in to Docker Hub
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Set tags
id: set-tags
run: |
REF_NAME=${GITHUB_REF#refs/tags/}
TAGS="${{ inputs.image_name }}:$REF_NAME"
if [[ "${{ inputs.add_latest }}" == "true" ]]; then
TAGS="$TAGS,${{ inputs.image_name }}:latest"
fi
echo "tags=$TAGS" >> $GITHUB_OUTPUT
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ${{ inputs.dockerfile }}
push: true
tags: ${{ steps.set-tags.outputs.tags }}
target: ${{ inputs.target }}
+128
View File
@@ -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"
+32
View File
@@ -0,0 +1,32 @@
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 }}
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 }}
+27 -44
View File
@@ -1,50 +1,33 @@
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 }}
- 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 }}
+3
View File
@@ -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
Binary file not shown.
-2
View File
@@ -1,2 +0,0 @@
nodeLinker: node-modules
+30
View File
@@ -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.10
date-released: "2026-01-07"
+35 -254
View File
@@ -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: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![License: Apache](https://img.shields.io/badge/License-apache-yellow.svg)](LICENSE)
[![Docker Pulls](https://img.shields.io/docker/pulls/solucetechnologies/portabase?color=brightgreen)](https://hub.docker.com/r/solucetechnologies/portabase)
[![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-lightgrey)](https://github.com/RostislavDugin/postgresus)
[![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-lightgrey)](https://github.com/Portabase/portabase)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-336791?logo=postgresql&logoColor=white)](https://www.postgresql.org/)
[![MySQL](https://img.shields.io/badge/MySQL-4479A1?logo=mysql&logoColor=white)](https://www.mysql.com/)
[![MariaDB](https://img.shields.io/badge/MariaDB-003545?logo=mariadb&logoColor=white)](https://mariadb.org/)
[![Self Hosted](https://img.shields.io/badge/self--hosted-yes-brightgreen)](https://github.com/RostislavDugin/postgresus)
[![Self Hosted](https://img.shields.io/badge/self--hosted-yes-brightgreen)](https://github.com/Portabase/portabase)
[![Open Source](https://img.shields.io/badge/open%20source-❤️-red)](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>
![portabase-ezgif com-video-to-gif-converter](https://github.com/user-attachments/assets/e3929e58-fefd-44cc-ab26-6f8e1a860c43)
![portabase-dashboard](https://github.com/user-attachments/assets/f5ec7204-79c0-43ac-ba17-f2ed682c39d6)
</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
[![Contributors](https://contrib.rocks/image?repo=Portabase/portabase)](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
[![Contributors](https://contrib.rocks/image?repo=Soluce-Technologies/portabase)](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/
+22 -12
View File
@@ -1,15 +1,25 @@
import {PageParams} from "@/types/next";
import {Metadata} from "next";
import {ForgotPasswordForm} from "@/components/wrappers/auth/forgot-password/forgot-password-form";
import {CardContent, CardHeader} from "@/components/ui/card";
export const metadata: Metadata = {
title: "Forgot Password",
};
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 }> }) {
export default async function RoutePage(props: PageParams<{}>) {
return (
<div className="mx-auto grid w-full gap-6">
<ForgotPasswordForm/>
</div>
)
}
<TooltipProvider>
<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>
</div>
</CardHeader>
<CardContent>
<ForgotPasswordForm/>
</CardContent>
</CardAuth>
</TooltipProvider>
);
}
+33
View File
@@ -0,0 +1,33 @@
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() {
const cookieStore = await cookies();
const token = cookieStore.get("better-auth.two_factor")?.value;
if (!token) {
redirect("/login");
}
return (
<TooltipProvider>
<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>
</div>
</CardHeader>
<CardContent>
<GuardForm/>
</CardContent>
</CardAuth>
</TooltipProvider>
);
}
+40 -5
View File
@@ -1,17 +1,52 @@
import {env} from "@/env.mjs";
import {LoginForm} from "@/components/wrappers/auth/login/login-form/login-form";
import {Metadata} from "next";
import {SUPPORTED_PROVIDERS} from "../../../portabase.config";
import {SocialAuthButtons} from "@/components/wrappers/auth/social-buttons";
import {TooltipProvider} from "@/components/ui/tooltip";
import {CardContent, CardHeader} from "@/components/ui/card";
import Link from "next/link";
import {Separator} from "@/components/ui/separator";
import {CardAuth} from "@/features/layout/card-auth";
export const metadata: Metadata = {
title: "Login",
};
export default async function SignInPage() {
const authGoogleEnabled = env.AUTH_GOOGLE_METHOD;
return (
<div className="mx-auto grid w-full gap-6">
<LoginForm authGoogleEnabled={authGoogleEnabled}/>
</div>
<TooltipProvider>
<CardAuth className="w-full">
<CardHeader>
<div className="grid gap-2 text-center mb-2">
<h1 className="text-3xl font-bold">Login</h1>
<p className="text-balance text-muted-foreground">Fill your login informations</p>
</div>
</CardHeader>
<CardContent className="space-y-4">
<LoginForm/>
{SUPPORTED_PROVIDERS.filter((p) => !p.isManual && p.isActive).length > 0 && (
<>
<div className="relative my-4 flex items-center justify-center overflow-hidden">
<Separator/>
<div className="px-2 text-center text-sm">OR</div>
<Separator/>
</div>
<SocialAuthButtons providers={SUPPORTED_PROVIDERS}/>
</>
)}
<div className="mt-4 text-center text-sm">
Don&apos;t have an account ?{" "}
<Link href="/register" className="underline">
Sign up
</Link>
</div>
</CardContent>
</CardAuth>
</TooltipProvider>
)
}
+56 -12
View File
@@ -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 }>) {
@@ -54,42 +49,10 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
<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";
@@ -18,7 +18,11 @@ export const metadata: Metadata = {
export default async function RoutePage(props: PageParams<{}>) {
const agents = await db.query.agent.findMany({
where: not(eq(drizzleDb.schemas.agent.isArchived, true))
where: not(eq(drizzleDb.schemas.agent.isArchived, true)),
with: {
databases: true
},
orderBy: (fields) => desc(fields.createdAt),
});
@@ -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>
);
}
@@ -1,12 +1,10 @@
import {PageParams} from "@/types/next";
import {notFound, redirect} from "next/navigation";
import {Page, PageActions, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
import {Page, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
import {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
import {DatabaseTabs} from "@/components/wrappers/dashboard/projects/database/database-tabs";
import {DatabaseKpi} from "@/components/wrappers/dashboard/projects/database/database-kpi";
import {EditButton} from "@/components/wrappers/dashboard/database/edit-button/edit-button";
import {CronButton} from "@/components/wrappers/dashboard/database/cron-button/cron-button";
import {db} from "@/db";
import {eq, and, inArray} from "drizzle-orm";
import * as drizzleDb from "@/db";
@@ -16,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;
@@ -92,22 +91,24 @@ export default async function RoutePage(props: PageParams<{
const isMember = activeMember?.role === "member";
return (
<Page>
<div className="justify-between gap-2 sm:flex">
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full">
<div className=" w-full md:w-fit">
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="min-w-full md:min-w-fit ">
{capitalizeFirstLetter(dbItem.name)}
</div>
{!isMember && (
<div className="flex items-center gap-2 md:justify-between w-full">
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
{/* Do not delete*/}
{/*<EditButton/>*/}
<RetentionPolicySheet database={dbItem}/>
<CronButton database={dbItem}/>
<AlertPolicyModal database={dbItem} notificationChannels={activeOrganizationChannels} organizationId={organization.id} />
<AlertPolicyModal database={dbItem} notificationChannels={activeOrganizationChannels}
organizationId={organization.id}/>
<ImportModal database={dbItem}/>
</div>
<div className="flex items-center gap-2">
<BackupButton disable={isAlreadyBackup} databaseId={databaseId}/>
@@ -115,6 +116,8 @@ export default async function RoutePage(props: PageParams<{
</div>
)}
</PageTitle>
</div>
{dbItem.description && (
@@ -78,8 +78,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"/>
@@ -10,6 +10,7 @@ import {EditButtonSettings} from "@/components/wrappers/dashboard/settings/edit-
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";
export const metadata: Metadata = {
title: "Settings",
@@ -20,32 +21,34 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
const user = await currentUser();
const activeMember = await getActiveMember()
if (!organization) {
if (!organization || !activeMember) {
notFound();
}
const notificationChannels = await getOrganizationChannels(organization.id)
const isMember = activeMember?.role === "member";
const isOwner = activeMember?.role === "owner";
const permissions = computeOrganizationPermissions(activeMember);
return (
<Page>
<PageHeader>
<PageTitle className="flex items-center">
Organization settings
{!isMember && organization.slug !== "default" && (
{permissions.canManageSettings && organization.slug !== "default" && (
<EditButtonSettings/>
)}
</PageTitle>
<PageActions>
{isOwner && organization.slug !== "default" && (
{permissions.canManageDangerZone && organization.slug !== "default" && (
<DeleteOrganizationButton organizationSlug={organization.slug}/>
)}
</PageActions>
</PageHeader>
<PageContent>
<OrganizationTabs
activeMember={activeMember}
organization={organization}
notificationChannels={notificationChannels}
/>
@@ -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>
+13 -13
View File
@@ -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>
);
}
-62
View File
@@ -1,62 +0,0 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageTitle} from "@/features/layout/page";
import {notFound} from "next/navigation";
import {UserForm} from "@/components/wrappers/dashboard/profile/user-form/user-form";
import {Badge} from "@/components/ui/badge";
import {AvatarWithUpload} from "@/components/wrappers/dashboard/profile/avatar/avatar-with-upload";
import {currentUser} from "@/lib/auth/current-user";
import {getAccounts, getSessions} from "@/lib/auth/auth";
import {Metadata} from "next";
export const metadata: Metadata = {
title: "Profile",
};
export default async function RoutePage(props: PageParams<{}>) {
const user = await currentUser();
if (!user) {
return notFound();
}
if (user.role !== "user" && user.role !== "admin" && user.role !== "superadmin") {
return notFound();
}
const sessions = await getSessions();
const accounts = await getAccounts();
return (
<Page>
<div className="justify-between gap-2 sm:flex">
<PageTitle className="flex items-center">
<AvatarWithUpload
user={{
...user,
image: user.image ?? null,
role: user.role ?? null,
banned: user.banned ?? null,
banReason: user.banReason ?? null,
banExpires: user.banExpires ?? null,
deletedAt: user.deletedAt ? new Date(user.deletedAt) : null,
}}
/>
{user.name}
<Badge className="ml-3 hidden lg:block">{user.role}</Badge>
</PageTitle>
</div>
<PageContent>
<UserForm
userId={user.id}
sessions={sessions}
accounts={accounts}
defaultValues={{
name: user.name,
email: user.email,
role: user.role ?? undefined,
}}
/>
</PageContent>
</Page>
);
}
+5 -3
View File
@@ -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));
-1
View File
@@ -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(
+4 -3
View File
@@ -7,7 +7,7 @@ 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 {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types";
import {EDbmsSchema} from "@/db/schema/types";
import {ServerActionResult} from "@/types/action-type";
import {SafeActionResult} from "next-safe-action";
import {ZodString} from "zod";
@@ -66,10 +66,11 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
const [databaseUpdated] = await dbClient
.update(drizzleDb.schemas.database)
.set({
.set(withUpdatedAt({
name: db.name,
agentId: agent.id,
lastContact: lastContact
})
}))
.where(eq(drizzleDb.schemas.database.id, existingDatabase.id))
.returning();
+7 -5
View File
@@ -7,6 +7,7 @@ import {db} from "@/db";
import {EDbmsSchema} from "@/db/schema/types";
import {eq} from "drizzle-orm";
import {isUuidv4} from "@/utils/verify-uuid";
import {withUpdatedAt} from "@/db/utils";
export type databaseAgent = {
name: string,
@@ -15,12 +16,13 @@ export type databaseAgent = {
}
export type Body = {
version: string,
databases: databaseAgent[]
}
// Function to test the get file url presigned local
export async function GET(request: Request) {
const url = await getFileUrlPresignedLocal({fileName:"d4a7fa35-2506-4d01-a612-a8ef2e2cc1c5.dump"})
const url = await getFileUrlPresignedLocal({fileName: "d4a7fa35-2506-4d01-a612-a8ef2e2cc1c5.dump"})
return Response.json({
message: url
})
@@ -39,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}
@@ -52,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)
@@ -60,7 +60,10 @@ export async function POST(
await db
.update(drizzleDb.schemas.agent)
.set({lastContact: lastContact})
.set(withUpdatedAt({
version: body.version,
lastContact: lastContact
}))
.where(eq(drizzleDb.schemas.agent.id, agentId));
eventEmitter.emit('modification', {update: true});
@@ -72,7 +75,6 @@ export async function POST(
},
databases: databasesResponse
}
console.log(response)
return Response.json(response)
} catch (error) {
+1 -13
View File
@@ -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});
// }
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+1 -3
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 20 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

+8 -5
View File
@@ -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>
);
+13 -6
View File
@@ -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>
+5 -5
View File
@@ -3,11 +3,11 @@ name: portabase-prod
services:
app:
build:
context: .
dockerfile: docker/dockerfile/Dockerfile
target: prod
# image: solucetechnologies/portabase:1.1.4-rc.1
# build:
# context: .
# dockerfile: docker/dockerfile/Dockerfile
# target: prod
image: solucetechnologies/portabase:1.1.4-rc.2
ports:
- '8887:80'
environment:
+26 -34
View File
@@ -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 -3
View File
@@ -3,10 +3,10 @@
set -euo pipefail
echo "▶ Running Drizzle codegen..."
npx drizzle-kit generate
pnpm drizzle-kit generate
echo "▶ Applying migrations..."
npx drizzle-kit migrate
pnpm drizzle-kit migrate
echo "▶ Starting Next.js dev server..."
exec npm run dev
exec pnpm dev
+7
View File
@@ -41,6 +41,13 @@ const nextConfig: NextConfig = {
typescript: {
ignoreBuildErrors: true,
},
experimental: {
serverActions: {
bodySizeLimit: "10gb",
},
proxyClientMaxBodySize: '10gb',
},
async headers() {
return [
{
+7 -4
View File
@@ -1,6 +1,6 @@
{
"name": "portabase",
"version": "1.1.4",
"version": "1.1.10",
"private": true,
"scripts": {
"dev": "next dev --turbopack -p 8887",
@@ -52,7 +52,7 @@
"@zenstackhq/runtime": "2.14.2",
"argon2": "^0.43.0",
"bcrypt": "^6.0.0",
"better-auth": "1.4.2",
"better-auth": "1.4.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -66,7 +66,7 @@
"lucide-react": "^0.553.0",
"minio": "^8.0.5",
"motion": "^12.23.24",
"next": "16.0.7",
"next": "16.0.10",
"next-safe-action": "^7.10.8",
"next-themes": "^0.4.6",
"node-cron": "^4.2.1",
@@ -80,6 +80,7 @@
"react-dropzone": "^14.3.8",
"react-email": "^4.0.13",
"react-hook-form": "^7.56.3",
"react-qr-code": "^2.0.18",
"react-resizable-panels": "^3.0.2",
"react-twc": "^1.4.2",
"react-use-measure": "^2.1.7",
@@ -96,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",
@@ -110,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",
@@ -117,5 +120,5 @@
"typescript": "^5.8.3",
"zenstack": "2.14.2"
},
"packageManager": "yarn@4.9.1"
"packageManager": "pnpm@10.27.0"
}
+14158
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
onlyBuiltDependencies:
- zenstack
+46
View File
@@ -1,4 +1,17 @@
export interface AuthProviderConfig {
id: "google" | "github" | "credential";
isActive: boolean;
icon: string;
isManual?: boolean;
credentials?: {
clientId: string;
clientSecret: string;
};
}
export const PORTABASE_DEFAULT_SETTINGS = {
SECURITY: {
CSP: {
@@ -62,3 +75,36 @@ export const PORTABASE_DEFAULT_SETTINGS = {
},
},
};
export const SUPPORTED_PROVIDERS: AuthProviderConfig[] = [
{
id: "google",
icon: "hugeicons:chrome",
isActive: Boolean(process.env.AUTH_GOOGLE_METHOD),
// isManual: true,
credentials: {
clientId: process.env.AUTH_GOOGLE_ID || "",
clientSecret: process.env.AUTH_GOOGLE_SECRET || "",
},
},
{
id: "github",
icon: "iconoir:github",
isActive: Boolean(process.env.AUTH_GITHUB_METHOD),
credentials: {
clientId: process.env.AUTH_GITHUB_ID || "",
clientSecret: process.env.AUTH_GITHUB_SECRET || "",
},
},
{
id: "credential",
isActive: true,
icon: "proicons:key",
isManual: true,
credentials: {
clientId: "",
clientSecret: "",
},
},
];
Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Executable
+95
View File
@@ -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,51 @@
import {Heading, Text, Section, Button} from "@react-email/components";
import {getServerUrl} from "@/utils/get-server-url";
import EmailLayout from "@/components/emails/email-layout";
interface EmailCreateUserProps {
firstname?: string;
oldEmail?: string;
newEmail?: string;
url: string;
}
export const EmailVerification = ({firstname, oldEmail, newEmail, url: urlVerification}: EmailCreateUserProps) => {
const serverUrl = new URL(getServerUrl());
const url = new URL(urlVerification);
url.hostname = serverUrl.hostname;
url.port = serverUrl.port == "80" ? "" : serverUrl.port;
const newUrl = url.toString();
return (
<EmailLayout preview="Portabase email verification">
<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 change the email address associated with your account.
</Text>
<Text className="text-[14px] text-black leading-[24px]">
If you did not request this change, please ignore this email. Your current email address will remain
unchanged. If this seems suspicious, contact your administrator.
</Text>
{oldEmail && newEmail ? (
<Text className="text-[14px] text-black leading-[24px]">
<strong>Old email address:</strong> {oldEmail}
<br/>
<strong>New email address:</strong> {newEmail}
</Text>
) : null}
<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={newUrl}>
Confirm email address
</Button>
</Section>
</EmailLayout>
);
};
export default EmailVerification;
@@ -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;
+13 -5
View File
@@ -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}/logo-title-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;
+850
View File
@@ -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,
};
+15 -1
View File
@@ -1,6 +1,6 @@
"use client";
import {Ref, useState} from "react";
import {Ref, useEffect, useState} from "react";
import {Check, X} from "lucide-react";
import {motion, AnimatePresence} from "framer-motion";
import {
@@ -22,6 +22,9 @@ interface PasswordStrengthInputProps {
};
label?: string;
description?: string;
disabled?: boolean;
onValidChange?: (isValid: boolean) => void;
}
const passwordTextsFields = {
@@ -48,6 +51,7 @@ export function PasswordStrengthInput({
field,
label,
description,
disabled, onValidChange
}: PasswordStrengthInputProps) {
const text = passwordTextsFields
const [isVisible, setIsVisible] = useState(false);
@@ -83,6 +87,14 @@ export function PasswordStrengthInput({
return text.strength.strong;
};
useEffect(() => {
if (strengthScore === requirements.length) {
onValidChange?.(true);
} else {
onValidChange?.(false);
}
}, [strengthScore, onValidChange]);
return (
<FormItem>
<FormLabel>{label ?? text.label}</FormLabel>
@@ -99,6 +111,8 @@ export function PasswordStrengthInput({
}}
ref={field.ref}
name={field.name}
disabled={disabled}
/>
</FormControl>
</div>
@@ -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>
)
}
);
};
@@ -1,113 +0,0 @@
"use client";
import {Card, CardContent, CardHeader} from "@/components/ui/card";
import {FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Form} from "@/components/ui/form";
import {Button} from "@/components/ui/button";
import {toast} from "sonner";
import {useMutation} from "@tanstack/react-query";
import {TooltipProvider} from "@/components/ui/tooltip";
import Link from "next/link";
import {PasswordInput} from "@/components/ui/password-input";
import {LoginSchema, LoginType} from "@/components/wrappers/auth/login/login-form/login-form.schema";
import {SocialAuthButton, SocialProviderType} from "@/components/wrappers/auth/login/button-auth/social-auth-button";
import {authClient, signIn} from "@/lib/auth/auth-client";
import {useRouter} from "next/navigation";
import {Icon} from "@iconify/react";
import {useEffect, useState} from "react";
import {Separator} from "@/components/ui/separator";
import {
ForgotPasswordSchema,
ForgotPasswordType
} from "@/components/wrappers/auth/forgot-password/forgot-password.schema";
import {ArrowLeft} from "lucide-react";
import {getServerUrl} from "@/utils/get-server-url";
export type ForgotPasswordFormProps = {};
export const ForgotPasswordForm = (props: ForgotPasswordFormProps) => {
const router = useRouter();
const form = useZodForm({
schema: ForgotPasswordSchema,
});
const mutation = useMutation({
mutationFn: async (values: ForgotPasswordType) => {
try {
const {data, error} = await authClient.requestPasswordReset({
email: values.email,
redirectTo: `${getServerUrl()}/reset-password`,
});
if (error) {
toast.error(error.message);
} else {
// @ts-ignore
toast.success(data.message);
}
} catch (err) {
console.error(err);
toast.error("Unexpected client error during login");
}
},
onError: (err: any) => {
toast.error(err.message || "Client error");
},
});
return (
<TooltipProvider>
<Card>
<CardHeader>
<div className="grid gap-2 text-center mb-2">
<h1 className="text-3xl font-bold">Forgot Password</h1>
<p className="text-balance text-muted-foreground">
Enter your email to reset your password
</p>
</div>
</CardHeader>
<CardContent>
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="email"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
autoComplete="email"
placeholder="example@portabase.io"
{...field}
/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<Button type="submit" disabled={mutation.isPending}>
Send reset link
</Button>
</Form>
<div className="mt-4 text-center text-sm flex items-center justify-center gap-1">
<ArrowLeft size={14} className="text-gray-400"/>
<Link href="/login" className="underline">
Go back
</Link>
</div>
</CardContent>
</Card>
</TooltipProvider>
);
};
@@ -0,0 +1,24 @@
"use client";
import { toast } from "sonner";
import { useRouter, useSearchParams } from "next/navigation";
import TwoFactorForm from "@/components/wrappers/dashboard/profile/form/2fa-form";
export const GuardForm = () => {
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("redirect") || "/dashboard";
return (
<TwoFactorForm
onSuccess={(success) => {
if (success) {
toast.success("Successfully logged in!");
//@ts-ignore
router.push(callbackUrl);
router.refresh();
}
}}
/>
);
};
@@ -1,55 +0,0 @@
"use client";
import {Button} from "@/components/ui/button";
import {signIn} from "@/lib/auth/auth-client";
import {JSX} from "react";
export type AuthButtonProps = {
providers: SocialProviderType[];
callBackURL?: string;
};
export type SocialProviderType = {
id:
| "github"
| "apple"
| "discord"
| "facebook"
| "microsoft"
| "google"
| "spotify"
| "twitch"
| "twitter"
| "dropbox"
| "kick"
| "linkedin"
| "gitlab"
| "tiktok"
| "reddit"
| "roblox"
| "vk";
name: string;
icon: JSX.Element;
};
export const SocialAuthButton = (props: AuthButtonProps): JSX.Element => {
return (
<div className="flex flex-col gap-4 mt-5">
{props.providers.map((provider) => (
<Button
key={provider.id}
aria-label={`Sign in with ${provider.name}`}
onClick={(e) => {
e.preventDefault();
void signIn.social({
provider: provider.id,
callbackURL: props.callBackURL ?? "/dashboard/profile",
});
}}
>
{provider.icon}
Sign in with {provider.name}
</Button>
))}
</div>
);
};
@@ -1,10 +1,10 @@
"use client";
import { z } from "zod";
import { zEmail } from "@/lib/zod";
export const ForgotPasswordSchema = z.object({
email: z
.string()
.min(1, "Email is required")
.email("Invalid email address"),
email: zEmail(),
});
export type ForgotPasswordType = z.infer<typeof ForgotPasswordSchema>;
export type ForgotPasswordType = z.infer<typeof ForgotPasswordSchema>;
@@ -0,0 +1,81 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Form } from "@/components/ui/form";
import { requestPasswordReset } from "@/lib/auth/auth-client";
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
import Link from "next/link";
import { ForgotPasswordSchema, ForgotPasswordType } from "./forgot-password-form.schema";
import { ArrowLeft } from "lucide-react";
export type ForgotPasswordFormProps = {
defaultValues?: ForgotPasswordType;
};
export const ForgotPasswordForm = (props: ForgotPasswordFormProps) => {
const form = useZodForm({
schema: ForgotPasswordSchema,
});
const mutation = useMutation({
mutationFn: async (values: ForgotPasswordType) => {
await requestPasswordReset(
{
email: values.email,
},
{
onSuccess: () => {
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);
},
}
);
},
});
return (
<Form
form={form}
className="flex flex-col gap-4 mb-1"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="email"
defaultValue=""
render={({ field }) => (
<FormItem>
<FormLabel>Email Address</FormLabel>
<FormControl>
<Input autoComplete="email" autoFocus
placeholder="example@portabase.io"
{...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-col items-center gap-y-6 w-full">
<ButtonWithLoading className="mt-2 w-full" isPending={mutation.isPending}>
Send reset link
</ButtonWithLoading>
<Link href="/login" className="group flex items-center text-sm hover:underline">
<ArrowLeft className="mr-1 size-4 text-muted-foreground transition-transform group-hover:-translate-x-1" />
Back to login
</Link>
</div>
</Form>
);
};
@@ -0,0 +1,75 @@
"use server";
import { ServerActionResult } from "@/types/action-type";
import { auth } from "@/lib/auth/auth";
import { zString } from "@/lib/zod";
import z from "zod";
import { db } from "@/db";
import {action} from "@/lib/safe-actions/actions";
//todo: to be continued...
export const forgotPasswordAction = action
.schema(
z.object({
schema: z.object({
email: zString(),
}),
redirectTo: zString().optional(),
})
)
.action(async ({ parsedInput }): Promise<ServerActionResult<null>> => {
try {
const user = await (await auth.$context).internalAdapter.findUserByEmail(parsedInput.schema.email);
if (!user) {
return {
success: false,
actionError: {
message: "password_reset",
cause: "user_not_found",
},
};
}
const existingToken = await db.query.verification.findFirst({
where: (verifications, { eq, and, gte }) =>
and(eq(verifications.value, user.user.id), gte(verifications.expiresAt, new Date(Date.now() + 15 * 60 * 1000))),
});
if (existingToken) {
return {
success: false,
actionError: {
message: "password_reset",
cause: "reset_already_requested",
},
};
}
// await (
// await auth.$context
// ).options.emailAndPassword
// .sendResetPassword(
// {
// user: user.user,
// url,
// token: verificationToken,
// },
// ctx.request
// )
// .catch((e) => {
// ctx.context.logger.error("Failed to send reset password email", e);
// });
return {
success: true,
};
} catch (error) {
return {
success: false,
actionError: {
message: "password_reset",
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
@@ -1,9 +1,11 @@
"use client";
import { z } from "zod";
import { zEmail, zString } from "@/lib/zod";
export const LoginSchema = z.object({
email: z.string().email({message: "Email is invalid"}),
password: z.string().nonempty({message: "Password could not be empty"}),
})
email: zEmail(),
password: zString().nonempty(),
});
export type LoginType = z.infer<typeof LoginSchema>;
@@ -1,177 +1,122 @@
"use client";
import {Card, CardContent, CardHeader} from "@/components/ui/card";
import {useEffect, useState} from "react";
import {useMutation} from "@tanstack/react-query";
import {toast} from "sonner";
import {FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Form} from "@/components/ui/form";
import {Button} from "@/components/ui/button";
import {toast} from "sonner";
import {useMutation} from "@tanstack/react-query";
import {TooltipProvider} from "@/components/ui/tooltip";
import Link from "next/link";
import {PasswordInput} from "@/components/ui/password-input";
import {LoginSchema, LoginType} from "@/components/wrappers/auth/login/login-form/login-form.schema";
import {SocialAuthButton, SocialProviderType} from "@/components/wrappers/auth/login/button-auth/social-auth-button";
import {signIn} from "@/lib/auth/auth-client";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import Link from "next/link";
import {useRouter} from "next/navigation";
import {Icon} from "@iconify/react";
import {useEffect, useState} from "react";
import {Separator} from "@/components/ui/separator";
import {PasswordInput} from "@/components/ui/password-input";
export type loginFormProps = {
defaultValues?: LoginType;
authGoogleEnabled: boolean;
};
export const LoginForm = (props: loginFormProps) => {
const router = useRouter();
const form = useZodForm({
schema: LoginSchema,
});
const [urlParams] = useState(() =>
new URLSearchParams(typeof window !== "undefined" ? window.location.search : "")
);
const [urlParams, setUrlParams] = useState<URLSearchParams>();
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
setUrlParams(urlParams);
const error = urlParams.get("error");
if (error?.includes("pending")) {
toast.error("Your account is not active.");
urlParams.delete("error");
window.history.replaceState({}, document.title, window.location.pathname + "?" + urlParams.toString());
}
}, [urlParams]);
if (error?.includes("invalid_or_expired_token")) {
toast.error("Password reset invalid token.");
urlParams.delete("error");
window.history.replaceState({}, document.title, window.location.pathname + "?" + urlParams.toString());
}
}, []);
const form = useZodForm({
schema: LoginSchema,
});
const router = useRouter();
const mutation = useMutation({
mutationFn: async (values: LoginType) => {
try {
const callbackURL =
urlParams.get("redirect")?.startsWith("/")
? urlParams.get("redirect")
: "/dashboard/profile";
const {error} = await signIn.email({
email: values.email,
await signIn.email(
{
password: values.password,
callbackURL: callbackURL ?? "/dashboard/profile",
});
email: values.email,
callbackURL: urlParams?.get("redirect") ?? "/dashboard",
},
{
onSuccess: (context) => {
if (context.data.twoFactorRedirect) {
//@ts-ignore
router.push("/guard?redirect=" + encodeURIComponent(context.data.callbackURL || "/dashboard"));
}
if (error) {
toast.error(error.message);
} else {
toast.success("Login success");
toast.success("Login success");
},
onError: (error) => {
toast.error(error.error.message);
},
}
} catch (err) {
console.error(err);
toast.error("Unexpected client error during login");
}
},
onError: (err: any) => {
toast.error(err.message || "Client error");
);
},
});
const availableProviders: SocialProviderType[] = [];
if (props.authGoogleEnabled) {
availableProviders.push({
id: "google",
name: "Google",
icon: <Icon icon="logos:google-icon" width="25" height="25"/>,
});
}
return (
<TooltipProvider>
<Card>
<CardHeader>
<div className="grid gap-2 text-center mb-2">
<h1 className="text-3xl font-bold">Login</h1>
<p className="text-balance text-muted-foreground">
Enter your information below to login
</p>
</div>
</CardHeader>
<CardContent>
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="email"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
autoComplete="email"
placeholder="example@portabase.io"
{...field}
/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
defaultValue=""
render={({field}) => (
<FormItem>
<div className="flex items-center justify-between">
<FormLabel>Password</FormLabel>
<div className="text-center text-sm">
<Link href="/forgot-password" className="hover:underline">
Forgot your password ?
</Link>
</div>
</div>
<FormControl>
<PasswordInput
autoComplete="current-password"
placeholder="Your password"
{...field}
/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Signing in..." : "Sign in"}
</Button>
</Form>
{availableProviders.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>
<Separator/>
<Form
form={form}
className="flex flex-col gap-4 mb-1"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="email"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Email Address</FormLabel>
<FormControl>
<Input autoComplete="email" autoFocus placeholder="exemple@portabase.io" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
defaultValue=""
render={({field}) => (
<FormItem>
<div className="flex items-center justify-between">
<FormLabel>Password</FormLabel>
<div className="text-center text-sm">
<Link href={"/forgot-password"} className="hover:underline ml-1">
Forgot your password ?
</Link>
</div>
</div>
)}
<SocialAuthButton
callBackURL={urlParams.get("redirect") ?? "/dashboard/profile"}
providers={availableProviders}
/>
<div className="mt-4 text-center text-sm">
Don&apos;t have an account ?{" "}
<Link href="/register" className="underline">
Sign up
</Link>
</div>
</CardContent>
</Card>
</TooltipProvider>
<FormControl>
<PasswordInput autoComplete="current-password webauthn"
placeholder={"Enter your password"} {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<ButtonWithLoading className="mt-2" isPending={mutation.isPending}>
Login
</ButtonWithLoading>
</Form>
);
};
@@ -0,0 +1,62 @@
"use server";
import { ServerActionResult } from "@/types/action-type";
import { auth } from "@/lib/auth/auth";
import { zPassword, zString } from "@/lib/zod";
import z from "zod";
import {action} from "@/lib/safe-actions/actions";
export const resetPasswordAction = action
.schema(
z.object({
schema: z.object({
password: zPassword(),
}),
token: zString(),
})
)
.action(async ({ parsedInput }): Promise<ServerActionResult<null>> => {
try {
const verification = await (await auth.$context).internalAdapter.findVerificationValue(`reset-password:${parsedInput.token}`);
if (!verification || verification.expiresAt < new Date()) {
return {
success: false,
actionError: {
message: "password_reset",
cause: "invalid_or_expired_token",
},
};
}
const user = await (await auth.$context).internalAdapter.findUserById(verification.value);
if (!user) {
return {
success: false,
actionError: {
message: "password_reset",
cause: "user_not_found",
},
};
}
await (
await auth.$context
).internalAdapter.updateUser(user.id, {
isDefaultPassword: false,
});
return {
success: true,
actionSuccess: {
message: "password_reset",
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "password_reset",
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
@@ -0,0 +1,21 @@
"use client";
import {z} from "zod";
import {zPassword} from "@/lib/zod";
export const ResetPasswordSchema = z
.object({
password: zPassword(),
confirmPassword: zPassword(),
})
.superRefine(({confirmPassword, password}, ctx) => {
if (confirmPassword !== password) {
ctx.addIssue({
code: "custom",
message: "Confirmation password does not match",
path: ["confirmPassword"],
});
}
});
export type ResetPasswordType = z.infer<typeof ResetPasswordSchema>;
@@ -0,0 +1,96 @@
"use client";
import {useMutation} from "@tanstack/react-query";
import {toast} from "sonner";
import {FormControl, FormField, FormItem, FormLabel, useZodForm} from "@/components/ui/form";
import {Form} from "@/components/ui/form";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import Link from "next/link";
import {ResetPasswordSchema, ResetPasswordType} from "./reset-password-form.schema";
import {PasswordStrengthInput} from "@/components/ui/password-input-indicator";
import {useRouter, useSearchParams} from "next/navigation";
import {ArrowLeft} from "lucide-react";
import {authClient} from "@/lib/auth/auth-client";
import {BetterAuthError} from "@/types/auth";
import {PasswordInput} from "@/components/ui/password-input";
export type ResetPasswordFormProps = {
defaultValues?: ResetPasswordType;
};
export const ResetPasswordForm = (props: ResetPasswordFormProps) => {
const searchParams = useSearchParams();
const form = useZodForm({
schema: ResetPasswordSchema,
});
const router = useRouter();
const mutation = useMutation({
mutationFn: async (values: ResetPasswordType) => {
const {data, error} = await authClient.resetPassword({
newPassword: values.password,
token: searchParams.get("token") || "",
});
if (error) throw error;
},
onSuccess: () => {
toast.success("Password successfully reset!");
setTimeout(() => router.push("/"), 1400);
},
onError: (error: BetterAuthError) => {
toast.error("An error occurred while resetting password");
},
});
return (
<Form
form={form}
className="flex flex-col gap-4 mb-1"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="password"
defaultValue=""
render={({field}) => (
<FormItem>
<PasswordStrengthInput label={"New password"} field={field}/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Confirmation password</FormLabel>
<FormControl>
<PasswordInput placeholder={"Enter your conformation password"} {...field} />
</FormControl>
</FormItem>
)}
/>
<div className="flex flex-col items-center gap-y-6 w-full">
<ButtonWithLoading className="mt-2 w-full" isPending={mutation.isPending}>
Reset
</ButtonWithLoading>
<Link href="/login" className="group flex items-center text-sm hover:underline">
<ArrowLeft
className="mr-1 size-4 text-muted-foreground transition-transform group-hover:-translate-x-1"/>
Back to login
</Link>
</div>
</Form>
);
};
@@ -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>
);
};
@@ -0,0 +1,66 @@
"use client";
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth/auth-client";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
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);
const [isLoading, setIsLoading] = useState<string | null>(null);
const handleSocialSignIn = async (providerId: string) => {
setIsLoading(providerId);
try {
const { error } = await authClient.signIn.social({
provider: providerId as "google" | "github",
callbackURL: "/dashboard",
});
if (error) {
toast.error("An error occurred while signing in with the provider. Please try again.");
} else {
toast.success("Redirecting to provider...");
}
} catch (err) {
toast.error("An error occurred while signing in with the provider. Please try again.");
} finally {
setIsLoading(null);
}
};
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" /> :
<Icon icon={provider.icon} className="h-4 w-4"/>
}
<span>{PROVIDERS_TEXT[provider.id].title}</span>
</Button>
))}
</div>
);
}
const PROVIDERS_TEXT = {
credential: {
title: "Password",
description: "Use your email address and password to sign in."
},
google: {
title: "Google",
description: "Sign in with your Google account."
},
github: {
title: "GitHub",
description: "Sign in with your GitHub account."
}
}
+11 -4
View File
@@ -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>
);
};
@@ -1,36 +1,3 @@
// import Link from "next/link";
// import {cn} from "@/lib/utils";
// import {Plus} from "lucide-react";
//
// type EmptyStatePlaceholderProps = {
// url?: string;
// text: string;
// className?: string;
// }
//
// export const EmptyStatePlaceholder = ({url, text, className}: EmptyStatePlaceholderProps) => {
// return (
// <div className={cn("",className)}>{url ?
// <Link
// href={url}
// className={cn(
// "h-full",
// "flex flex-col items-center justify-center w-full rounded-2xl border border-dashed border-muted p-6 lg:p-10",
// "hover:bg-muted/50 transition-colors text-muted-foreground hover:text-primary text-center space-y-2"
// )}
// >
// <Plus className="w-5 h-5 lg:w-6 lg:h-6"/>
// <span className="text-sm lg:text-base font-medium">{text}</span>
// </Link>
// :
// <div className="flex h-full flex-col items-center justify-center py-12 text-center">
// <p className="text-lg text-muted-foreground">{text}</p>
// </div>
// }
// </div>
//
// )
// }
import Link from "next/link";
import {cn} from "@/lib/utils";
import {Plus} from "lucide-react";
@@ -48,25 +15,27 @@ export const EmptyStatePlaceholder = ({
text,
className,
}: EmptyStatePlaceholderProps) => {
const content = (
const Container = (
<div
className={cn(
"flex h-full flex-col items-center justify-center w-full rounded-2xl border border-dashed border-muted p-6 lg:p-10",
"hover:bg-muted/50 transition-colors text-muted-foreground hover:text-primary text-center space-y-4 cursor-pointer",
onClick || url ? "cursor-pointer" : "cursor-default"
"hover:bg-muted/50 transition-colors text-muted-foreground hover:text-primary text-center space-y-4",
(onClick || url) && "cursor-pointer"
)}
{...(onClick ? {onClick} : url ? {asChild: true} : {})}
onClick={onClick}
>
<Plus className="w-5 h-5 lg:w-6 lg:h-6"/>
<div>
<p className="text-sm ">{text}</p>
</div>
<Plus className="w-5 h-5 lg:w-6 lg:h-6" />
<p className="text-sm">{text}</p>
</div>
);
return (
<div className={cn("", className)}>
{url ? <Link href={url}>{content}</Link> : content}
</div>
);
};
if (url) {
return (
<div className={cn(className)}>
<Link href={url}>{Container}</Link>
</div>
);
}
return <div className={cn(className)}>{Container}</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>
)
}
@@ -1,17 +1,5 @@
"use client"
import {ColumnDef} from "@tanstack/react-table";
import {Badge} from "@/components/ui/badge";
import {updateUserAction} from "@/components/wrappers/dashboard/profile/user-form/user-form.action";
import {useMutation} from "@tanstack/react-query";
import {toast} from "sonner";
import {useRouter} from "next/navigation";
import {useState} from "react";
import {Trash2} from "lucide-react";
import {deleteUserAction} from "@/components/wrappers/dashboard/profile/button-delete-account/delete-account.action";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {UserWithAccounts} from "@/db/schema/02_user";
import {authClient, useSession} from "@/lib/auth/auth-client";
import {providerSwitch} from "@/components/wrappers/common/provider-switch";
import {Organization} from "@/db/schema/03_organization";
export const organizationsColumnsAdmin: ColumnDef<Organization>[] = [
@@ -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);
},
@@ -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}/>,
},
];
}

Some files were not shown because too many files have changed in this diff Show More