mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
977f9344c7 | ||
|
|
2adb2f3b68 | ||
|
|
40a6e9e69f | ||
|
|
e1d1a3fb9a | ||
|
|
75120c0a56 | ||
|
|
33f1ef3bb3 | ||
|
|
b6e742333c | ||
|
|
9f8c66fc20 | ||
|
|
3e950652e8 | ||
|
|
98e40466a0 | ||
|
|
324d80a735 | ||
|
|
6770be63b6 | ||
|
|
89aa33ebd8 | ||
|
|
619c32f09d | ||
|
|
c471423775 | ||
|
|
c589b9b8b8 | ||
|
|
bae9ba0973 | ||
|
|
7bfc5c1d9b | ||
|
|
8bd3b0d731 | ||
|
|
06cd4de141 | ||
|
|
33eb2df023 | ||
|
|
540ecd4b60 | ||
|
|
237418f009 | ||
|
|
4447180e9e | ||
|
|
f111946592 | ||
|
|
475e404fae | ||
|
|
07ce5215d4 | ||
|
|
9f4895fd63 | ||
|
|
c18a9da0d3 | ||
|
|
8e3eb32631 | ||
|
|
f0598b3f59 | ||
|
|
011120fdf9 | ||
|
|
ff175e1edd | ||
|
|
3f56008c39 | ||
|
|
5df95bf7c1 | ||
|
|
23d52d0447 |
@@ -26,7 +26,7 @@ Please take a moment to review this guide. It will help you understand how to co
|
||||
|
||||
2. **Clone the repository**
|
||||
```bash
|
||||
git clone https://github.com/Soluce-Technologies/portabase
|
||||
git clone https://github.com/Portabase/portabase
|
||||
```
|
||||
|
||||
3. **Set up the development environment**
|
||||
@@ -7,9 +7,9 @@
|
||||
|
||||
We take security seriously and aim to support the following versions of the project with security updates:
|
||||
|
||||
| Version | Supported |
|
||||
|-----------|--------------------|
|
||||
| 1.x | ✅ Fully Supported |
|
||||
| Version | Supported |
|
||||
|---------|--------------------|
|
||||
| Latest | ✅ Fully Supported |
|
||||
|
||||
---
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
@@ -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 }}
|
||||
@@ -0,0 +1,128 @@
|
||||
name: GitHub Release
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
prerelease:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
make_latest:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
discord_title:
|
||||
required: true
|
||||
type: string
|
||||
discord_color:
|
||||
required: true
|
||||
type: number
|
||||
discord_footer:
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
DISCORD_WEBHOOK:
|
||||
required: true
|
||||
GH_TOKEN:
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Build Changelog
|
||||
id: build_changelog
|
||||
uses: mikepenz/release-changelog-builder-action@v5
|
||||
with:
|
||||
mode: "COMMIT"
|
||||
configurationJson: |
|
||||
{
|
||||
"template": "#{{CHANGELOG}}",
|
||||
"categories": [
|
||||
{
|
||||
"title": "## Feature",
|
||||
"labels": ["feat", "feature"]
|
||||
},
|
||||
{
|
||||
"title": "## Fix",
|
||||
"labels": ["fix", "bug"]
|
||||
},
|
||||
{
|
||||
"title": "## Other",
|
||||
"labels": []
|
||||
}
|
||||
],
|
||||
"label_extractor": [
|
||||
{
|
||||
"pattern": "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test){1}(\([\w\-\.]+\))?(!)?: ([\w ])+([\s\S]*)",
|
||||
"on_property": "title",
|
||||
"target": "$1"
|
||||
}
|
||||
]
|
||||
}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: false
|
||||
body: ${{ steps.build_changelog.outputs.changelog }}
|
||||
prerelease: ${{ inputs.prerelease }}
|
||||
make_latest: ${{ inputs.make_latest }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
- name: Send Discord Notification
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
run: |
|
||||
RELEASE_INFO=$(gh release view "${{ github.ref_name }}" -R ${{ github.repository }} --json name,url,body,author)
|
||||
|
||||
RELEASE_TITLE=$(echo "$RELEASE_INFO" | jq -r .name)
|
||||
if [ -z "$RELEASE_TITLE" ] || [ "$RELEASE_TITLE" = "null" ]; then RELEASE_TITLE="${{ github.ref_name }}"; fi
|
||||
|
||||
RELEASE_URL=$(echo "$RELEASE_INFO" | jq -r .url)
|
||||
RELEASE_BODY=$(echo "$RELEASE_INFO" | jq -r .body)
|
||||
|
||||
AUTHOR_NAME="Portabase"
|
||||
AUTHOR_ICON="https://github.com/Portabase.png"
|
||||
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg title "$RELEASE_TITLE" \
|
||||
--arg description "$RELEASE_BODY" \
|
||||
--arg url "$RELEASE_URL" \
|
||||
--arg author "$AUTHOR_NAME" \
|
||||
--arg icon "$AUTHOR_ICON" \
|
||||
--arg discord_title "${{ inputs.discord_title }}" \
|
||||
--arg discord_footer "${{ inputs.discord_footer }}" \
|
||||
--argjson discord_color ${{ inputs.discord_color }} \
|
||||
'{
|
||||
content: $discord_title,
|
||||
embeds: [{
|
||||
title: $title,
|
||||
url: $url,
|
||||
description: $description,
|
||||
color: $discord_color,
|
||||
author: {
|
||||
name: $author,
|
||||
icon_url: $icon
|
||||
},
|
||||
footer: {
|
||||
text: $discord_footer
|
||||
}
|
||||
}]
|
||||
}'
|
||||
)
|
||||
|
||||
curl -H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" \
|
||||
"$DISCORD_WEBHOOK"
|
||||
@@ -0,0 +1,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 }}
|
||||
@@ -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 }}
|
||||
@@ -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.
@@ -1,2 +0,0 @@
|
||||
nodeLinker: node-modules
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
cff-version: 1.2.0
|
||||
title: Portabase
|
||||
message: "If you use this software, please cite it as below."
|
||||
type: software
|
||||
authors:
|
||||
- family-names: Gauthereau
|
||||
given-names: Charles
|
||||
- family-names: Larcher
|
||||
given-names: Killian
|
||||
repository-code: https://github.com/Portabase/portabase
|
||||
url: https://portabase.io
|
||||
abstract: "Portabase is a free, open-source, self-hosted solution for database administration, providing backup and restore capabilities, scheduling, retention policies, notifications, and support for multiple storage backends. Its headless agent architecture enables connection to multiple database instances securely and efficiently."
|
||||
keywords:
|
||||
- docker
|
||||
- kubernetes
|
||||
- backups
|
||||
- postgresql
|
||||
- mysql
|
||||
- mariadb
|
||||
- devops
|
||||
- database
|
||||
- monitoring
|
||||
- s3
|
||||
- self-hosted
|
||||
- system-administration
|
||||
- web-ui
|
||||
- agent
|
||||
license: Apache-2.0
|
||||
version: 1.1.9-rc4
|
||||
date-released: "2026-01-06"
|
||||
@@ -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)
|
||||
[](https://hub.docker.com/r/solucetechnologies/portabase)
|
||||
[](https://github.com/RostislavDugin/postgresus)
|
||||
[](https://github.com/Portabase/portabase)
|
||||
|
||||
[](https://www.postgresql.org/)
|
||||
[](https://www.mysql.com/)
|
||||
[](https://mariadb.org/)
|
||||
[](https://github.com/RostislavDugin/postgresus)
|
||||
[](https://github.com/Portabase/portabase)
|
||||
[](https://github.com/Portabase/portabase)
|
||||
|
||||
|
||||
[![NextJS][NextJS]][NextJS-url]
|
||||
[![BetterAuth][BetterAuth]][BetterAuth-url]
|
||||
[![Drizzle][Drizzle]][Drizzle-url]
|
||||
[![ShadcnUI][ShadcnUI]][ShadcnUI-url]
|
||||
[![Docker][Docker]][Docker-url]
|
||||
|
||||
<p>
|
||||
<strong>
|
||||
<a href="https://portabase.io">Documentation</a> •
|
||||
<a href="https://www.youtube.com/watch?v=D9uFrGxLc4s">Demo</a> •
|
||||
<a href="#installation">Installation</a> •
|
||||
<a href="#contributing">Contributing</a> •
|
||||
<a href="https://github.com/Soluce-Technologies/portabase/issues/new?labels=bug&template=bug-report---.md">Report Bug</a> •
|
||||
<a href="https://github.com/Soluce-Technologies/portabase/issues/new?labels=enhancement&template=feature-request---.md">Request Feature</a>
|
||||
<a href="https://portabase.io">Website</a> •
|
||||
<a href="https://portabase.io/docs">Documentation</a> •
|
||||
<a href="https://www.youtube.com/watch?v=hvLbX5LN1UE">Demo</a> •
|
||||
<a href="https://portabase.io/docs/dashboard/setup">Installation</a> •
|
||||
<a href="https://github.com/Portabase/portabase/issues/new?labels=bug&template=bug-report---.md">Report Bug</a> •
|
||||
<a href="https://github.com/Portabase/portabase/issues/new?labels=enhancement&template=feature-request---.md">Request Feature</a>
|
||||
</strong>
|
||||
</p>
|
||||
|
||||

|
||||

|
||||
|
||||
|
||||
</div>
|
||||
|
||||
## 📚 Table of Contents
|
||||
## Installation
|
||||
|
||||
- [About The Project](#-about-the-project)
|
||||
- [Getting Started](#-getting-started)
|
||||
- [Usage](#-usage)
|
||||
- [Roadmap](#-roadmap)
|
||||
- [Contributing](#-contributing)
|
||||
- [License](#-license)
|
||||
- [Contact](#-contact)
|
||||
- [Acknowledgments](#-acknowledgments)
|
||||
You have 4 ways to install Portabase:
|
||||
|
||||
---
|
||||
- Automated CLI (recommended) - [details](https://portabase.io/docs/dashboard/setup#cli)
|
||||
- Docker Compose setup - [details](https://portabase.io/docs/dashboard/setup#docker)
|
||||
- Kubernetes with Helm (soon)
|
||||
- Development setup - [details](https://portabase.io/docs/dashboard/setup#development)
|
||||
|
||||
## ✨ About The Project
|
||||
|
||||
**Portabase** is a server dashboard tool designed to simplify the backup and restoration of your database instances. It
|
||||
integrates seamlessly with Portabase agents for managing operations securely and efficiently.
|
||||
|
||||
GitHub Repository: [Portabase](https://github.com/Soluce-Technologies/portabase)
|
||||
|
||||
### 🔧 Built With
|
||||
|
||||
- [![NextJS][NextJS]][NextJS-url] (v16 with App Router)
|
||||
- [![Drizzle][Drizzle]][Drizzle-url]
|
||||
- [![ShadcnUI][ShadcnUI]][ShadcnUI-url]
|
||||
- [![BetterAuth][BetterAuth]][BetterAuth-url]
|
||||
- [![Docker][Docker]][Docker-url]
|
||||
**Ensure Docker is installed on your machine before getting started.**
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Getting Started
|
||||
## Contributors
|
||||
|
||||
### Installation
|
||||
[](https://github.com/Portabase/portabase/graphs/contributors)
|
||||
|
||||
Ensure Docker is installed on your machine before getting started.
|
||||
|
||||
### Option 1: Docker Compose Setup
|
||||
|
||||
Create a `docker-compose.yml` file with the following configuration:
|
||||
|
||||
```yaml
|
||||
name: portabase
|
||||
|
||||
services:
|
||||
|
||||
portabase:
|
||||
image: solucetechnologies/portabase:latest
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- '8887:80'
|
||||
environment:
|
||||
- TZ="Europe/Paris"
|
||||
volumes:
|
||||
- portabase-private:/app/private
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
container_name: portabase-app
|
||||
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
environment:
|
||||
- POSTGRES_DB=<your_database>
|
||||
- POSTGRES_USER=<database_user>
|
||||
- POSTGRES_PASSWORD=<database_password>
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -U <database_user> -d <your_database>" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
portabase-private:
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
If you use reverse proxy like
|
||||
Traefik : [Check this link](https://portabase.io/docs/portabase/advanced-topics/reverse-proxy)
|
||||
|
||||
### Option 2: Locally (Development)
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/Soluce-Technologies/portabase
|
||||
cd portabase
|
||||
```
|
||||
2. Start the development environment:
|
||||
```bash
|
||||
docker compose up
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Usage
|
||||
|
||||
Portabase provides a web dashboard to manage your database instances and backups.
|
||||
It currently supports:
|
||||
|
||||
- **PostgreSQL**
|
||||
- **MySQL**
|
||||
|
||||
### Process
|
||||
|
||||
1. **Access the dashboard** – Open `http://localhost:8887` in your browser.
|
||||
2. **Sign up** – Register the first user, who will automatically have the **Admin** role in the default workspace.
|
||||
3. **Add your first agent** – Follow [this guide](https://github.com/Soluce-Technologies/agent-portabase) for setup
|
||||
instructions.
|
||||
4. **Create organizations and projects** – Link your databases to projects to enable backups and restores.
|
||||
5. **Configure backup policies** – Define schedules (hourly, daily, weekly, or monthly) and retention rules.
|
||||
6. **Choose a storage provider** – Select where backups will be stored (local, S3, etc.).
|
||||
7. **Save and start** – Portabase validates your configuration and starts automated backups based on your defined
|
||||
policies.
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
- [ ] Add changelog
|
||||
- [ ] Enhance documentation
|
||||
- [ ] Implement testing procedures
|
||||
- [ ] Include release file
|
||||
- [ ] Improve security
|
||||
- [ ] Enhance UX/UI
|
||||
- [ ] Improve workspace management system
|
||||
- [x] Migrate to Drizzle ORM
|
||||
- [ ] Add multi-language support
|
||||
- [ ] Extend multi-database support:
|
||||
- [x] PostgreSQL
|
||||
- [ ] MongoDB
|
||||
- [x] MySQL
|
||||
- [x] MariaDB
|
||||
|
||||
Check out [open issues](https://github.com/Soluce-Technologies/portabase/issues) for more.
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome and appreciated! Here's how to get started:
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a new branch:
|
||||
```bash
|
||||
git checkout -b feature/YourFeature
|
||||
```
|
||||
3. Commit your changes:
|
||||
```bash
|
||||
git commit -m "Add YourFeature"
|
||||
```
|
||||
4. Push to the branch:
|
||||
```bash
|
||||
git push origin feature/YourFeature
|
||||
```
|
||||
5. Open a pull request
|
||||
|
||||
Give the project a ⭐ if you like it!
|
||||
|
||||
### Top Contributors
|
||||
|
||||
[](https://github.com/Soluce-Technologies/portabase/graphs/contributors)
|
||||
|
||||
---
|
||||
|
||||
## Developer Notes
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```yml
|
||||
# Environment
|
||||
NODE_ENV=production
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://devuser:changeme@db:5432/devdb?schema=public
|
||||
|
||||
# Project Info
|
||||
PROJECT_NAME="Portabase"
|
||||
PROJECT_DESCRIPTION="Portabase is a powerful database manager"
|
||||
PROJECT_URL=http://app.portabase.io
|
||||
PROJECT_SECRET=
|
||||
|
||||
# SMTP (Email)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=
|
||||
|
||||
# Google OAuth
|
||||
AUTH_GOOGLE_ID=
|
||||
AUTH_GOOGLE_SECRET=
|
||||
AUTH_GOOGLE_METHOD=
|
||||
|
||||
# S3/MinIO Configuration
|
||||
S3_ENDPOINT=http://app.s3.portabase.io
|
||||
S3_ACCESS_KEY=
|
||||
S3_SECRET_KEY=
|
||||
S3_BUCKET_NAME=portabase
|
||||
S3_PORT=9000
|
||||
S3_USE_SSL=true
|
||||
|
||||
# Storage Backend: 'local' or 's3'
|
||||
STORAGE_TYPE=local
|
||||
|
||||
# Retention
|
||||
RETENTION_CRON="* * * * *"
|
||||
```
|
||||
|
||||
To get more information about env variables, check
|
||||
that [link](https://portabase.io/docs/portabase/advanced-topics/environment)
|
||||
|
||||
### Semantic Versioning
|
||||
|
||||
Use the following format for Docker image versioning:
|
||||
|
||||
```bash
|
||||
major.minor.patch-rc.release
|
||||
# Example: 1.0.0-rc.1
|
||||
|
||||
major.minor.patch-rc.release-tag
|
||||
# Example: 1.0.0-rc.1-dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
## License
|
||||
|
||||
Distributed under the Apache License. See `LICENSE.txt` for more details.
|
||||
|
||||
---
|
||||
|
||||
## 📬 Contact
|
||||
|
||||
- Killian Larcher - killian.larcher@soluce-technologies.com
|
||||
- Charles Gauthereau - charles.gauthereau@soluce-technologies.com
|
||||
- Project Link: [Portabase GitHub](https://github.com/Soluce-Technologies/portabase)
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
Thanks to all contributors and the open-source community!
|
||||
|
||||
[Docker]: https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=fff&style=for-the-badge
|
||||
|
||||
@@ -304,5 +84,6 @@ Thanks to all contributors and the open-source community!
|
||||
[Drizzle-url]: https://orm.drizzle.team/
|
||||
|
||||
[ShadcnUI-url]: https://ui.shadcn.com/
|
||||
|
||||
[Docker-url]: https://www.docker.com/
|
||||
|
||||
|
||||
@@ -24,18 +24,16 @@ export default async function SignInPage() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<LoginForm />
|
||||
<LoginForm/>
|
||||
|
||||
{SUPPORTED_PROVIDERS.filter((p) => !p.isManual).length > 0 && (
|
||||
{SUPPORTED_PROVIDERS.filter((p) => !p.isManual && p.isActive).length > 0 && (
|
||||
<>
|
||||
|
||||
<div className="relative my-4 flex items-center justify-center overflow-hidden">
|
||||
<Separator/>
|
||||
<div className="px-2 text-center text-sm">OR</div>
|
||||
<Separator/>
|
||||
</div>
|
||||
|
||||
<SocialAuthButtons />
|
||||
<SocialAuthButtons providers={SUPPORTED_PROVIDERS}/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -50,7 +48,5 @@ export default async function SignInPage() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/
|
||||
import {notFound} from "next/navigation";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {eq, not} from "drizzle-orm";
|
||||
import {desc, eq, not} from "drizzle-orm";
|
||||
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
|
||||
import {Metadata} from "next";
|
||||
|
||||
@@ -21,7 +21,8 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
where: not(eq(drizzleDb.schemas.agent.isArchived, true)),
|
||||
with: {
|
||||
databases: true
|
||||
}
|
||||
},
|
||||
orderBy: (fields) => desc(fields.createdAt),
|
||||
});
|
||||
|
||||
console.log(agents);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,45 +1,37 @@
|
||||
FROM node:22-alpine AS base
|
||||
|
||||
ENV YARN_VERSION=4.9.1
|
||||
|
||||
RUN apk add --update --no-cache \
|
||||
tzdata \
|
||||
build-base \
|
||||
libc6-compat \
|
||||
openssl \
|
||||
make \
|
||||
tzdata
|
||||
|
||||
FROM base AS build-env
|
||||
|
||||
RUN apk add --update --no-cache \
|
||||
build-base \
|
||||
g++ \
|
||||
jpeg-dev \
|
||||
cairo-dev \
|
||||
giflib-dev \
|
||||
pango-dev \
|
||||
make \
|
||||
libtool \
|
||||
autoconf \
|
||||
automake \
|
||||
libpng
|
||||
automake
|
||||
|
||||
RUN corepack enable && corepack prepare yarn@${YARN_VERSION}
|
||||
RUN yarn set version ${YARN_VERSION}
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
|
||||
FROM base AS deps
|
||||
|
||||
|
||||
FROM build-env AS deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY .yarn ./.yarn
|
||||
COPY .yarnrc.yml ./
|
||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
|
||||
RUN pnpm i --frozen-lockfile
|
||||
|
||||
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn; \
|
||||
elif [ -f package-lock.json ]; then npm ci; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
|
||||
FROM base AS dev
|
||||
FROM build-env AS dev
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -52,8 +44,11 @@ RUN chmod +x /app/docker/entrypoints/app-dev-entrypoint.sh
|
||||
ENTRYPOINT ["sh","/app/docker/entrypoints/app-dev-entrypoint.sh"]
|
||||
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
|
||||
|
||||
|
||||
|
||||
FROM build-env AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
@@ -61,12 +56,11 @@ COPY . .
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn run build; \
|
||||
elif [ -f package-lock.json ]; then npm run build; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
RUN pnpm run build
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
FROM base AS prod
|
||||
@@ -109,5 +103,3 @@ ENV HOSTNAME="0.0.0.0"
|
||||
USER nextjs
|
||||
|
||||
ENTRYPOINT ["sh","/app/app-prod-entrypoint.sh"]
|
||||
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
set -euo pipefail
|
||||
|
||||
echo "▶ Running Drizzle codegen..."
|
||||
npx drizzle-kit generate
|
||||
pnpm drizzle-kit generate
|
||||
|
||||
echo "▶ Applying migrations..."
|
||||
npx drizzle-kit migrate
|
||||
pnpm drizzle-kit migrate
|
||||
|
||||
echo "▶ Starting Next.js dev server..."
|
||||
exec npm run dev
|
||||
exec pnpm dev
|
||||
+3
-1
@@ -43,8 +43,10 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: "50mb",
|
||||
bodySizeLimit: "10gb",
|
||||
},
|
||||
proxyClientMaxBodySize: '10gb',
|
||||
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "portabase",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.9-rc4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 8887",
|
||||
@@ -97,6 +97,7 @@
|
||||
"devDependencies": {
|
||||
"@iconify/react": "^6.0.0",
|
||||
"@react-email/preview-server": "4.3.2",
|
||||
"@react-email/render": "^2.0.1",
|
||||
"@tailwindcss/postcss": "^4.1.7",
|
||||
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
||||
"@types/node": "^22.15.18",
|
||||
@@ -111,6 +112,7 @@
|
||||
"eslint": "^9.39.0",
|
||||
"eslint-config-next": "^16.0.1",
|
||||
"eslint-plugin-tailwindcss": "^3.18.0",
|
||||
"framer-motion": "^12.24.7",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"tsx": "^4.19.4",
|
||||
@@ -118,5 +120,5 @@
|
||||
"typescript": "^5.8.3",
|
||||
"zenstack": "2.14.2"
|
||||
},
|
||||
"packageManager": "yarn@4.9.1"
|
||||
"packageManager": "pnpm@10.27.0"
|
||||
}
|
||||
|
||||
Generated
+14168
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
onlyBuiltDependencies:
|
||||
- zenstack
|
||||
+13
-13
@@ -1,9 +1,9 @@
|
||||
import {Chrome, KeyRound, LucideIcon} from "lucide-react";
|
||||
|
||||
|
||||
export interface AuthProviderConfig {
|
||||
id: "google" | "github" | "credential";
|
||||
icon: LucideIcon;
|
||||
isActive: boolean;
|
||||
icon: string;
|
||||
isManual?: boolean;
|
||||
credentials?: {
|
||||
clientId: string;
|
||||
@@ -80,27 +80,27 @@ export const PORTABASE_DEFAULT_SETTINGS = {
|
||||
export const SUPPORTED_PROVIDERS: AuthProviderConfig[] = [
|
||||
{
|
||||
id: "google",
|
||||
icon: Chrome,
|
||||
icon: "hugeicons:chrome",
|
||||
isActive: Boolean(process.env.AUTH_GOOGLE_METHOD),
|
||||
// isManual: true,
|
||||
credentials: {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID || "",
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
|
||||
clientId: process.env.AUTH_GOOGLE_ID || "",
|
||||
clientSecret: process.env.AUTH_GOOGLE_SECRET || "",
|
||||
},
|
||||
},
|
||||
/*
|
||||
{
|
||||
id: "github",
|
||||
name: "GitHub",
|
||||
icon: Github,
|
||||
description: "Connexion via GitHub",
|
||||
icon: "iconoir:github",
|
||||
isActive: Boolean(process.env.AUTH_GITHUB_METHOD),
|
||||
credentials: {
|
||||
clientId: process.env.GITHUB_CLIENT_ID || "",
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET || "",
|
||||
clientId: process.env.AUTH_GITHUB_ID || "",
|
||||
clientSecret: process.env.AUTH_GITHUB_SECRET || "",
|
||||
},
|
||||
},*/
|
||||
},
|
||||
{
|
||||
id: "credential",
|
||||
icon: KeyRound,
|
||||
isActive: true,
|
||||
icon: "proicons:key",
|
||||
isManual: true,
|
||||
credentials: {
|
||||
clientId: "",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
if [ -z "$1" ]; then
|
||||
echo "Usage: ./release <version>"
|
||||
echo "Example: ./release v1.0.0"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION=$1
|
||||
CLEAN_VERSION=${VERSION#v}
|
||||
CURRENT_DATE=$(date +%Y-%m-%d)
|
||||
|
||||
echo "Preparing release $VERSION..."
|
||||
|
||||
echo "Updating package.json to version $CLEAN_VERSION..."
|
||||
sed -i "s/\"version\": \".*\"/\"version\": \"$CLEAN_VERSION\"/" package.json
|
||||
|
||||
echo "Updating CITATION.cff..."
|
||||
sed -i "s/^version: .*/version: $CLEAN_VERSION/" CITATION.cff
|
||||
sed -i "s/^date-released: .*/date-released: \"$CURRENT_DATE\"/" CITATION.cff
|
||||
|
||||
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,42 @@
|
||||
import * as React from "react";
|
||||
import EmailLayout from "./email-layout";
|
||||
import {Heading, Text, Section, Button} from "@react-email/components";
|
||||
import {getServerUrl} from "@/utils/get-server-url";
|
||||
import {env} from "@/env.mjs";
|
||||
|
||||
interface EmailCreateUserProps {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export const EmailCreateUser = ({email, password}: EmailCreateUserProps) => {
|
||||
const baseUrl = getServerUrl();
|
||||
|
||||
|
||||
return (
|
||||
<EmailLayout preview="Portabase Dashboard">
|
||||
<Heading className="mx-0 my-[30px] p-0 text-center font-normal text-[24px] text-black">
|
||||
Your account on {env.PROJECT_NAME} has just been created!
|
||||
</Heading>
|
||||
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
<strong>Email: </strong>{email}
|
||||
</Text>
|
||||
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
<strong>Default password: </strong>{password}
|
||||
</Text>
|
||||
|
||||
<Section className="mt-[32px] mb-[32px] text-center">
|
||||
<Button
|
||||
className="rounded bg-[#000000] px-5 py-3 text-center font-semibold text-[12px] text-white no-underline"
|
||||
href={baseUrl}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
</Section>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailCreateUser;
|
||||
@@ -10,11 +10,19 @@ export const EmailLayout = ({ children, preview }: PropsWithChildren<{ preview?:
|
||||
<Tailwind>
|
||||
<Html>
|
||||
<Head />
|
||||
{preview ? <Preview>{preview}</Preview> : <Preview>Please check your mails</Preview>}
|
||||
<Body className="bg-gray-100 py-4" style={{ fontFamily: "Arial, sans-serif" }}>
|
||||
<Container className="bg-white border border-gray-200 p-12">
|
||||
<Img src={`${baseUrl}/images/logo-dark.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>
|
||||
|
||||
@@ -5,9 +5,12 @@ import { Button } from "@/components/ui/button";
|
||||
import { authClient } from "@/lib/auth/auth-client";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {SUPPORTED_PROVIDERS} from "../../../../portabase.config";
|
||||
import {AuthProviderConfig} from "../../../../portabase.config";
|
||||
import {Icon} from "@iconify/react";
|
||||
|
||||
export function SocialAuthButtons({ providers }: { providers: AuthProviderConfig[] }) {
|
||||
const socialProviders = providers.filter(p => p.isActive && !p.isManual);
|
||||
|
||||
export function SocialAuthButtons() {
|
||||
const [isLoading, setIsLoading] = useState<string | null>(null);
|
||||
|
||||
const handleSocialSignIn = async (providerId: string) => {
|
||||
@@ -30,16 +33,15 @@ export function SocialAuthButtons() {
|
||||
}
|
||||
};
|
||||
|
||||
const socialProviders = SUPPORTED_PROVIDERS.filter((p) => !p.isManual);
|
||||
|
||||
if (socialProviders.length === 0) return null;
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{socialProviders.map((provider) => (
|
||||
<Button key={provider.id} variant="outline" className="w-full gap-2" onClick={() => handleSocialSignIn(provider.id)} disabled={!!isLoading}>
|
||||
{isLoading === provider.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <provider.icon className="h-4 w-4" />}
|
||||
{isLoading === provider.id ? <Loader2 className="h-4 w-4 animate-spin" /> :
|
||||
<Icon icon={provider.icon} className="h-4 w-4"/>
|
||||
}
|
||||
<span>{PROVIDERS_TEXT[provider.id].title}</span>
|
||||
</Button>
|
||||
))}
|
||||
|
||||
@@ -43,13 +43,17 @@ export function ComboBox<T = string>(props: ComboBoxProps<T>) {
|
||||
<PopoverTrigger asChild>
|
||||
{sideBar ? (
|
||||
<SidebarMenuButton>
|
||||
<label className="max-w-[170px] truncate">
|
||||
{value ? choices.find((c) => c.value === value)?.label : "Select choice..."}
|
||||
<ChevronDown className="ml-auto"/>
|
||||
</label>
|
||||
<ChevronDown className="ml-auto"/>
|
||||
</SidebarMenuButton>
|
||||
) : (
|
||||
<Button variant="outline" role="combobox" aria-expanded={open}
|
||||
className="w-full justify-between">
|
||||
{value ? choices.find((c) => c.value === value)?.label : "Select choice..."}
|
||||
<label className="max-w-[170px] truncate">
|
||||
{value ? choices.find((c) => c.value === value)?.label : "Select choice..."}
|
||||
</label>
|
||||
<ChevronDown className="opacity-50"/>
|
||||
</Button>
|
||||
)}
|
||||
@@ -59,7 +63,7 @@ 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>
|
||||
@@ -80,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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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,100 @@
|
||||
"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 || "",
|
||||
});
|
||||
console.log(result)
|
||||
const inner = result?.data;
|
||||
if (inner?.success) {
|
||||
toast.success("User Successfully updated");
|
||||
onSuccess?.();
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error("An error occurred");
|
||||
onSuccess?.();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={async (values) => {
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter a name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input disabled placeholder="Fill user email" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-4 justify-end">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<ButtonWithLoading type="submit" isPending={mutation.isPending}>Validate</ButtonWithLoading>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import {User} from "@/db/schema/02_user";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from "@/components/ui/dialog";
|
||||
import {AdminUserEditForm} from "./admin-user-edit-form";
|
||||
|
||||
type AdminUserEditPasswordProps = {
|
||||
open: boolean;
|
||||
user: User;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const AdminUserEdit = ({user, open, onOpenChange}: AdminUserEditPasswordProps) => {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit ${user.name}'s profile</DialogTitle>
|
||||
<DialogDescription>Update following information</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<AdminUserEditForm
|
||||
defaultValues={{
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
id: user.id,
|
||||
}}
|
||||
onSuccess={() => onOpenChange(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import {useRouter} from "next/navigation";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
|
||||
import {Organization} from "@/db/schema/03_organization";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {UserSchema, UserType} from "@/components/wrappers/dashboard/admin/users/user.schema";
|
||||
import {toast} from "sonner";
|
||||
import {createUserAction} from "@/components/wrappers/dashboard/admin/users/user.action";
|
||||
|
||||
type AdminUserFormProps = {
|
||||
onSuccess?: () => void;
|
||||
organizations: Organization[];
|
||||
};
|
||||
|
||||
export const AdminUserForm = ({onSuccess, organizations}: AdminUserFormProps) => {
|
||||
|
||||
|
||||
const router = useRouter();
|
||||
const form = useZodForm({
|
||||
schema: UserSchema,
|
||||
});
|
||||
|
||||
|
||||
const onCancel = () => {
|
||||
form.reset();
|
||||
onSuccess?.();
|
||||
};
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: UserType) => {
|
||||
const result = await createUserAction(data);
|
||||
const inner = result?.data;
|
||||
if (inner?.success) {
|
||||
toast.success("User Successfully created");
|
||||
onSuccess?.();
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error("An error occurred");
|
||||
onSuccess?.();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={async (values) => {
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter a name" {...field} value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Fill user email" {...field} value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
|
||||
<div className="flex gap-4 justify-end">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<ButtonWithLoading type="submit" isPending={mutation.isPending}>
|
||||
Validate
|
||||
</ButtonWithLoading>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
import { DataTable } from "@/components/wrappers/common/table/data-table";
|
||||
import {usersListColumns} from "@/components/wrappers/dashboard/admin/users/table-colums";
|
||||
import {User} from "@/db/schema/02_user";
|
||||
|
||||
type AdminUserListProps = {
|
||||
users: User[];
|
||||
};
|
||||
|
||||
export const AdminUserList = ({ users }: AdminUserListProps) => {
|
||||
return <DataTable columns={usersListColumns()} data={users} enablePagination={true} enableSelect={false} />;
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import {ColumnDef} from "@tanstack/react-table";
|
||||
import {User} from "@/db/schema/02_user";
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
|
||||
import {formatLocalizedDate, timeAgo} from "@/utils/date-formatting";
|
||||
import {Tooltip, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
|
||||
import {TooltipContent} from "@radix-ui/react-tooltip";
|
||||
import {Info} from "lucide-react";
|
||||
import {Table, TableBody, TableCell, TableRow} from "@/components/ui/table";
|
||||
import {UserActionsCell} from "@/components/wrappers/dashboard/admin/users/user-actions-cell";
|
||||
|
||||
export function usersListColumns(): ColumnDef<User>[] {
|
||||
|
||||
return [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Profile",
|
||||
cell: ({row}) => {
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="flex flex-row items-center gap-x-2">
|
||||
<Avatar>
|
||||
<AvatarImage src={row.original.image ?? ""} alt={row.original.name}/>
|
||||
<AvatarFallback>
|
||||
{row.original.name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-row items-center gap-x-2">
|
||||
<div className="font-medium">{row.original.name}</div>
|
||||
<Info size={16} aria-hidden="true" className="text-muted-foreground"/>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="bg-background shadow-lg border border-border z-20 rounded-md"
|
||||
side="bottom">
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>Joined</TableCell>
|
||||
<TableCell
|
||||
className="text-right text-muted-foreground">{formatLocalizedDate(row.original.createdAt)}</TableCell>
|
||||
</TableRow>
|
||||
{/*{row.original.lastConnectedAt && (*/}
|
||||
{/* <TableRow>*/}
|
||||
{/* <TableCell>Last connected</TableCell>*/}
|
||||
{/* <TableCell className="text-right text-muted-foreground">*/}
|
||||
{/* {formatLocalizedDate(row.original.lastConnectedAt)} ({timeAgo(row.original.lastConnectedAt, locale)})*/}
|
||||
{/* </TableCell>*/}
|
||||
{/* </TableRow>*/}
|
||||
{/*)}*/}
|
||||
{row.original.lastChangedPasswordAt && (
|
||||
<TableRow>
|
||||
<TableCell>Last password change</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground">
|
||||
{formatLocalizedDate(row.original.lastChangedPasswordAt)} ({timeAgo(row.original.lastChangedPasswordAt)})
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "email",
|
||||
header: "Email",
|
||||
},
|
||||
{
|
||||
accessorKey: "role",
|
||||
header: "Role",
|
||||
cell: ({row}) => {
|
||||
const role = row.original.role!;
|
||||
return <Badge>{role}</Badge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
cell: ({row}) => <UserActionsCell user={row.original}/>,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import {useState} from "react";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {MoreHorizontal, Settings, Trash2, RotateCcwKey, UserCog} from "lucide-react";
|
||||
import {authClient} from "@/lib/auth/auth-client";
|
||||
import {cn} from "@/lib/utils";
|
||||
import {User} from "@/db/schema/02_user";
|
||||
import {AdminUserChangePassword} from "./admin-user-change-password-modal";
|
||||
import {AdminUserEdit} from "./admin-user-edit-modal";
|
||||
import {AdminUserChangeRoleModal} from "@/components/wrappers/dashboard/admin/users/admin-user-change-role-modal";
|
||||
import {AdminDeleteUserModal} from "@/components/wrappers/dashboard/admin/users/admin-user-delete-modal";
|
||||
|
||||
interface UserActionsCellProps {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export function UserActionsCell({user}: UserActionsCellProps) {
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isModalChangePasswordOpen, setIsModalChangePasswordOpen] = useState(false);
|
||||
const [isModalEditUserOpen, setIsModalEditUserOpen] = useState(false);
|
||||
const [isModalDeleteOpen, setIsModalDeleteOpen] = useState(false);
|
||||
const {data: session, isPending, error} = authClient.useSession();
|
||||
if (isPending || error) return null;
|
||||
const isCurrentUser = session?.user?.id === user.id;
|
||||
const isSuperAdmin = session?.user?.role === "superadmin";
|
||||
|
||||
if (isCurrentUser || user.role === "superadmin") return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminUserChangeRoleModal user={user} open={isModalOpen} onOpenChange={setIsModalOpen}/>
|
||||
<AdminDeleteUserModal user={user} open={isModalDeleteOpen} onOpenChange={setIsModalDeleteOpen}/>
|
||||
<AdminUserChangePassword user={user} open={isModalChangePasswordOpen}
|
||||
onOpenChange={setIsModalChangePasswordOpen}/>
|
||||
<AdminUserEdit user={user} open={isModalEditUserOpen} onOpenChange={setIsModalEditUserOpen}/>
|
||||
<div className={cn("flex items-center space-x-2")}>
|
||||
<Button variant="outline" size="icon" onClick={() => setIsModalChangePasswordOpen(true)}>
|
||||
<RotateCcwKey className="w-4 h-4"/>
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="w-4 h-4"/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => setIsModalOpen(true)}>
|
||||
<Settings className="w-4 h-4 mr-2"/>
|
||||
Role
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setIsModalEditUserOpen(true)}>
|
||||
<UserCog className="w-4 h-4 mr-2"/>
|
||||
Edit User
|
||||
</DropdownMenuItem>
|
||||
{isSuperAdmin && (
|
||||
<>
|
||||
<DropdownMenuSeparator/>
|
||||
<DropdownMenuItem onSelect={() => setIsModalDeleteOpen(true)} className="text-red-600">
|
||||
<Trash2 className="w-4 h-4 mr-2"/>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
"use server";
|
||||
|
||||
import * as drizzleDb from "@/db";
|
||||
import {ServerActionResult} from "@/types/action-type";
|
||||
import {render} from "@react-email/render";
|
||||
import {UserSchema} from "@/components/wrappers/dashboard/admin/users/user.schema";
|
||||
import {extractNameFromEmail} from "@/utils/name-from-email";
|
||||
import {generateValidPassword} from "@/utils/password";
|
||||
import {auth} from "@/lib/auth/auth";
|
||||
import {z} from "zod";
|
||||
import {Organization} from "@/db/schema/03_organization";
|
||||
import {db} from "@/db";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
|
||||
import {zEmail, zString} from "@/lib/zod";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
import {
|
||||
addMemberOrganizationAction
|
||||
} from "@/components/wrappers/dashboard/admin/organizations/organization/details/add-member.action";
|
||||
import {sendEmail} from "@/lib/email/email-helper";
|
||||
import EmailCreateUser from "@/components/emails/email-create-user";
|
||||
import {SignUpUser} from "@/types/auth";
|
||||
import {createUserDb} from "@/db/services/user";
|
||||
import {User} from "@/db/schema/02_user";
|
||||
|
||||
export const createUserAction = userAction.schema(UserSchema).action(async ({parsedInput}): Promise<ServerActionResult<User>> => {
|
||||
try {
|
||||
const password = generateValidPassword();
|
||||
|
||||
const userData: SignUpUser = {
|
||||
name: parsedInput.name || extractNameFromEmail(parsedInput.email),
|
||||
email: parsedInput.email,
|
||||
password: password,
|
||||
theme: "dark",
|
||||
role: "user",
|
||||
};
|
||||
|
||||
const newUser = await createUserDb(userData);
|
||||
|
||||
if (newUser) {
|
||||
|
||||
await sendEmail({
|
||||
to: parsedInput.email,
|
||||
subject: "Your account is created",
|
||||
html: await render(EmailCreateUser({
|
||||
password: password,
|
||||
email: parsedInput.email,
|
||||
})),
|
||||
});
|
||||
|
||||
const defaultOrganization = await db.query.organization.findFirst({
|
||||
where: eq(drizzleDb.schemas.organization.slug, "default"),
|
||||
});
|
||||
|
||||
if (defaultOrganization) {
|
||||
await auth.api.addMember({
|
||||
body: {
|
||||
userId: newUser.id,
|
||||
organizationId: defaultOrganization.id,
|
||||
role: "admin",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: newUser,
|
||||
actionSuccess: {
|
||||
message: "user_created",
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "user_created",
|
||||
cause: "Unknown error",
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "user_created",
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const updateUserAction = userAction
|
||||
.schema(
|
||||
z.object({
|
||||
id: zString(),
|
||||
name: zString().optional(),
|
||||
email: zEmail(),
|
||||
})
|
||||
)
|
||||
.action(async ({parsedInput, ctx}): Promise<ServerActionResult<{}>> => {
|
||||
try {
|
||||
|
||||
|
||||
const [updatedUser] = await db.update(drizzleDb.schemas.user).set(withUpdatedAt({
|
||||
name: parsedInput.name ? parsedInput.name : extractNameFromEmail(parsedInput.email),
|
||||
email: parsedInput.email,
|
||||
emailVerified: false
|
||||
})).where(eq(drizzleDb.schemas.user.id, parsedInput.id)).returning();
|
||||
|
||||
|
||||
if (updatedUser) {
|
||||
return {
|
||||
success: true,
|
||||
actionSuccess: {
|
||||
message: "user_updated",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "user_updated",
|
||||
cause: "Unknown error",
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "user_updated",
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const setSuperAdminOwnerOfOrganizationsOwnedByUser = userAction
|
||||
.schema(
|
||||
z.object({
|
||||
userId: z.string(),
|
||||
})
|
||||
)
|
||||
.action(async ({parsedInput}): Promise<ServerActionResult<Organization[]>> => {
|
||||
try {
|
||||
const organizationsWhereUserIsMemberAndOwner = await db.query.member.findMany({
|
||||
where: and(eq(drizzleDb.schemas.member.role, "owner"), eq(drizzleDb.schemas.member.userId, parsedInput.userId)),
|
||||
with: {
|
||||
organization: true,
|
||||
},
|
||||
});
|
||||
|
||||
const superAdminUser = await db.query.user.findFirst();
|
||||
if (!superAdminUser) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "set_super_admin_owner_of_organizations_owned_by_user",
|
||||
cause: "Unknown error",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
for (let {organization} of organizationsWhereUserIsMemberAndOwner) {
|
||||
await addMemberOrganizationAction({
|
||||
userId: superAdminUser.id,
|
||||
organizationId: organization.id,
|
||||
role: "owner",
|
||||
});
|
||||
}
|
||||
const organizations = organizationsWhereUserIsMemberAndOwner.map(
|
||||
(organizationWhereUserIsMemberAndOwner) => organizationWhereUserIsMemberAndOwner.organization
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: organizations as unknown as Organization[],
|
||||
actionSuccess: {
|
||||
message: "set_super_admin_owner_of_organizations_owned_by_user",
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "error_set_super_admin_owner_of_organizations_owned_by_user",
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from "zod";
|
||||
import { zEmail, zString } from "@/lib/zod";
|
||||
|
||||
export const UserSchema = z.object({
|
||||
email: zEmail(),
|
||||
name: zString(),
|
||||
});
|
||||
|
||||
export type UserType = z.infer<typeof UserSchema>;
|
||||
|
||||
export const UserEditSchema = z.object({
|
||||
email: zEmail(),
|
||||
name: zString(),
|
||||
});
|
||||
|
||||
export type UserEditType = z.infer<typeof UserEditSchema>;
|
||||
@@ -1,6 +1,7 @@
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
import {getAccounts, getSession, getSessions} from "@/lib/auth/auth";
|
||||
import {LoggedInButtonClient} from "./logged-in-button";
|
||||
import {SUPPORTED_PROVIDERS} from "../../../../../../portabase.config";
|
||||
|
||||
export const LoggedInButton = async () => {
|
||||
const user = await currentUser();
|
||||
@@ -18,6 +19,7 @@ export const LoggedInButton = async () => {
|
||||
// @ts-ignore
|
||||
currentSession={currentSession.session}
|
||||
accounts={accounts}
|
||||
providers={SUPPORTED_PROVIDERS.filter((p) => p.isActive)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,62 +1,27 @@
|
||||
// import { ChevronUp } from "lucide-react";
|
||||
//
|
||||
// import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
|
||||
// import { currentUser } from "@/lib/auth/current-user";
|
||||
// import { SidebarMenuButton } from "@/components/ui/sidebar";
|
||||
// import { getAccounts, getSession, getSessions } from "@/lib/auth/auth";
|
||||
// import {LoggedInDropdown} from "@/components/wrappers/dashboard/common/logged-in/logged-in-dropdown";
|
||||
//
|
||||
// export const LoggedInButton = async () => {
|
||||
// const user = await currentUser();
|
||||
// const sessions = await getSessions();
|
||||
// const currentSession = await getSession();
|
||||
// const accounts = await getAccounts();
|
||||
//
|
||||
// if (!user) return null;
|
||||
//
|
||||
//
|
||||
// return (
|
||||
// <>
|
||||
// <LoggedInDropdown
|
||||
// // @ts-ignore
|
||||
// user={user}
|
||||
// // @ts-ignore
|
||||
// sessions={sessions}
|
||||
// // @ts-ignore
|
||||
// currentSession={currentSession.session}
|
||||
// // @ts-ignore
|
||||
// accounts={accounts}
|
||||
// >
|
||||
// <SidebarMenuButton>
|
||||
//
|
||||
// <Avatar className="size-6">
|
||||
// <AvatarFallback>{user?.name[0].toUpperCase()}</AvatarFallback>
|
||||
// {user?.image ? <AvatarImage src={user?.image} alt={`${user.name ?? "-"}'s profile picture`} /> : null}
|
||||
// </Avatar>
|
||||
//
|
||||
// <span className="first-letter:capitalize">{user?.name}</span>
|
||||
// <ChevronUp className="ml-auto" />
|
||||
// </SidebarMenuButton>
|
||||
// </LoggedInDropdown>
|
||||
// </>
|
||||
// );
|
||||
// };
|
||||
"use client";
|
||||
|
||||
import {ChevronUp} from "lucide-react";
|
||||
import {ChevronsUpDown, ChevronUp} from "lucide-react";
|
||||
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
|
||||
import {SidebarMenuButton} from "@/components/ui/sidebar";
|
||||
import {LoggedInDropdown} from "./logged-in-dropdown";
|
||||
import {Account, Session, User} from "better-auth";
|
||||
import {AuthProviderConfig} from "../../../../../../portabase.config";
|
||||
|
||||
type LoggedInButtonClientProps = {
|
||||
user: User;
|
||||
sessions: Session[];
|
||||
currentSession: Session;
|
||||
accounts: Account[];
|
||||
user: User,
|
||||
sessions: Session[],
|
||||
currentSession: Session,
|
||||
accounts: Account[],
|
||||
providers: AuthProviderConfig[]
|
||||
}
|
||||
|
||||
export const LoggedInButtonClient = ({user, sessions, currentSession, accounts}: LoggedInButtonClientProps) => {
|
||||
export const LoggedInButtonClient = ({
|
||||
user,
|
||||
sessions,
|
||||
currentSession,
|
||||
accounts,
|
||||
providers
|
||||
}: LoggedInButtonClientProps) => {
|
||||
|
||||
return (
|
||||
<LoggedInDropdown
|
||||
@@ -68,15 +33,25 @@ export const LoggedInButtonClient = ({user, sessions, currentSession, accounts}:
|
||||
currentSession={currentSession}
|
||||
// @ts-ignore
|
||||
accounts={accounts}
|
||||
providers={providers}
|
||||
>
|
||||
<SidebarMenuButton type="button">
|
||||
<Avatar className="size-6">
|
||||
<AvatarFallback>{user.name[0].toUpperCase()}</AvatarFallback>
|
||||
{user.image && <AvatarImage src={user.image}/>}
|
||||
</Avatar>
|
||||
|
||||
<span className="first-letter:capitalize">{user.name}</span>
|
||||
<ChevronUp className="ml-auto"/>
|
||||
<SidebarMenuButton type="button" className="h-auto justify-between py-2" >
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="size-6">
|
||||
<AvatarFallback>{user.name[0].toUpperCase()}</AvatarFallback>
|
||||
{user.image && <AvatarImage src={user.image}/>}
|
||||
</Avatar>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="text-sm font-medium first-letter:capitalize max-w-[170px] truncate">{user.name}</span>
|
||||
<span
|
||||
className="text-xs text-muted-foreground max-w-[170px] truncate"
|
||||
title={user.email}
|
||||
>
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50"/>
|
||||
</SidebarMenuButton>
|
||||
</LoggedInDropdown>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
|
||||
import { signOut } from "@/lib/auth/auth-client";
|
||||
import {ProfileModal} from "@/components/wrappers/dashboard/common/profile/profile-modal";
|
||||
import {Account, Session, User} from "@/db/schema/02_user";
|
||||
import {AuthProviderConfig} from "../../../../../../portabase.config";
|
||||
|
||||
export type LoggedInDropdownProps = PropsWithChildren<{
|
||||
user: User;
|
||||
@@ -14,9 +15,11 @@ export type LoggedInDropdownProps = PropsWithChildren<{
|
||||
currentSession: Session;
|
||||
accounts: Account[];
|
||||
children: ReactNode;
|
||||
providers: AuthProviderConfig[]
|
||||
|
||||
}>;
|
||||
|
||||
export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, children }: LoggedInDropdownProps) => {
|
||||
export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, children, providers }: LoggedInDropdownProps) => {
|
||||
const router = useRouter();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
@@ -29,6 +32,7 @@ export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, chi
|
||||
accounts={accounts}
|
||||
open={isModalOpen}
|
||||
onOpenChange={setIsModalOpen}
|
||||
providers={providers}
|
||||
/>
|
||||
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {ProfileAccount} from "@/components/wrappers/dashboard/profile/profile-ac
|
||||
import {ProfileAppearance} from "@/components/wrappers/dashboard/profile/profile-apperance";
|
||||
import {ProfileSecurity} from "@/components/wrappers/dashboard/profile/profile-security";
|
||||
import {ProfileGeneral} from "@/components/wrappers/dashboard/profile/profile-general";
|
||||
import {AuthProviderConfig} from "../../../../../../portabase.config";
|
||||
|
||||
type ProfileModalProps = {
|
||||
open: boolean;
|
||||
@@ -18,9 +19,11 @@ type ProfileModalProps = {
|
||||
currentSession: Session;
|
||||
accounts: Account[];
|
||||
onOpenChange: (open: boolean) => void;
|
||||
providers: AuthProviderConfig[]
|
||||
|
||||
};
|
||||
|
||||
export const ProfileModal = ({ user, sessions, currentSession, accounts, open, onOpenChange }: ProfileModalProps) => {
|
||||
export const ProfileModal = ({ user, sessions, currentSession, accounts, open, onOpenChange, providers }: ProfileModalProps) => {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[95vw] h-[90vh] max-w-md lg:max-w-[1000px] lg:h-[800px] p-0 overflow-hidden flex flex-col outline-none gap-0 rounded-xl bg-background">
|
||||
@@ -46,7 +49,7 @@ export const ProfileModal = ({ user, sessions, currentSession, accounts, open, o
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="providers" className="mt-0 h-full p-6 lg:p-10 outline-none focus-visible:ring-0">
|
||||
<ProfileProviders accounts={accounts} />
|
||||
<ProfileProviders accounts={accounts} providers={providers} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="account" className="mt-0 h-full p-6 lg:p-10 outline-none focus-visible:ring-0">
|
||||
|
||||
@@ -34,7 +34,6 @@ export function AppSidebar() {
|
||||
<SidebarMenu className="mb-2">
|
||||
<SidebarMenuItem className="p-2">
|
||||
<LoggedInButton/>
|
||||
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
<SideBarFooterCredit/>
|
||||
|
||||
@@ -42,6 +42,7 @@ export const uploadBackupAction = userAction
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
|
||||
const fileSize = file.size;
|
||||
const uuid = uuidv4();
|
||||
const fileName = `imported_${uuid}${fileExtension}`;
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
@@ -85,6 +86,7 @@ export const uploadBackupAction = userAction
|
||||
status: 'success',
|
||||
databaseId: database.id,
|
||||
file: fileName,
|
||||
fileSize: fileSize,
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import {toast} from "sonner";
|
||||
import {User as BetterAuthUser} from "better-auth";
|
||||
import {User} from "@/db/schema/02_user";
|
||||
import {authClient} from "@/lib/auth/auth-client";
|
||||
|
||||
export type organizationFormProps = {
|
||||
defaultValues?: OrganizationWithMembers;
|
||||
@@ -36,6 +37,8 @@ export type organizationFormProps = {
|
||||
};
|
||||
|
||||
export const OrganizationForm = (props: organizationFormProps) => {
|
||||
const {data: activeOrganization, refetch: refetchActiveOrga} = authClient.useActiveOrganization();
|
||||
const {data: organizations, refetch} = authClient.useListOrganizations();
|
||||
|
||||
const router = useRouter();
|
||||
const isCreate = !Boolean(props.defaultValues);
|
||||
@@ -75,6 +78,8 @@ export const OrganizationForm = (props: organizationFormProps) => {
|
||||
onSuccess: async (result) => {
|
||||
if (result?.data?.success) {
|
||||
toast.success(result.data.actionSuccess?.message || "Organization updated successfully.");
|
||||
refetch()
|
||||
refetchActiveOrga()
|
||||
router.push("/dashboard/settings");
|
||||
} else {
|
||||
// @ts-ignore
|
||||
|
||||
@@ -4,7 +4,7 @@ import { UploadIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { uploadImageAction } from "@/features/upload/public/upload.action";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { updateImageUserAction } from "@/components/wrappers/dashboard/profile2/avatar/avatar.action";
|
||||
import { updateImageUserAction } from "@/components/wrappers/dashboard/profile/actions/avatar.action";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { User } from "@/db/schema/02_user";
|
||||
import React, {ChangeEvent} from "react";
|
||||
|
||||
@@ -12,13 +12,16 @@ import {useRouter} from "next/navigation";
|
||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
|
||||
import {Alert, AlertDescription} from "@/components/ui/alert";
|
||||
import {SetPasswordProfileProviderModal} from "./modal/set-password-modal";
|
||||
import {SUPPORTED_PROVIDERS} from "../../../../../portabase.config";
|
||||
import {AuthProviderConfig} from "../../../../../portabase.config";
|
||||
import {Icon} from "@iconify/react";
|
||||
|
||||
interface ProfileProviderProps {
|
||||
accounts: Account[];
|
||||
providers: AuthProviderConfig[]
|
||||
|
||||
}
|
||||
|
||||
export function ProfileProviders({accounts}: ProfileProviderProps) {
|
||||
export function ProfileProviders({accounts, providers}: ProfileProviderProps) {
|
||||
const router = useRouter();
|
||||
const totalConnected = accounts.length;
|
||||
const [loadingProvider, setLoadingProvider] = useState<string | null>(null);
|
||||
@@ -70,7 +73,7 @@ export function ProfileProviders({accounts}: ProfileProviderProps) {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{SUPPORTED_PROVIDERS.map((provider) => {
|
||||
{providers.map((provider) => {
|
||||
const linkedAccount = accounts.find((acc) => acc.providerId === provider.id);
|
||||
const isConnected = !!linkedAccount;
|
||||
// const canUnlink = totalConnected > 1 || (totalConnected === 1 && !provider.isManual);
|
||||
@@ -83,7 +86,7 @@ export function ProfileProviders({accounts}: ProfileProviderProps) {
|
||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted/30 transition-colors">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center">
|
||||
<provider.icon className="w-5 h-5"/>
|
||||
<Icon icon={provider.icon} className="w-5 h-5"/>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<div className="font-medium flex items-center gap-2">
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
"use client";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { UploadIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { uploadImageAction } from "@/features/upload/public/upload.action";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { updateImageUserAction } from "@/components/wrappers/dashboard/profile2/avatar/avatar.action";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { User } from "@/db/schema/02_user";
|
||||
import {ChangeEvent} from "react";
|
||||
|
||||
export type AvatarWithUploadProps = {
|
||||
user: User;
|
||||
};
|
||||
|
||||
export const AvatarWithUpload = (props: AvatarWithUploadProps) => {
|
||||
const user = props.user;
|
||||
const router = useRouter();
|
||||
|
||||
const submitImage = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.set("file", file);
|
||||
const uploadImage = await uploadImageAction(formData);
|
||||
const data = uploadImage?.data?.data;
|
||||
|
||||
if (uploadImage?.serverError || !data) {
|
||||
console.log(uploadImage?.serverError);
|
||||
toast.error(uploadImage?.serverError);
|
||||
return;
|
||||
}
|
||||
|
||||
const updateUser = await updateImageUserAction(data.url);
|
||||
const dataUser = updateUser?.data?.data;
|
||||
|
||||
if (updateUser?.serverError || !dataUser) {
|
||||
console.log(updateUser?.serverError);
|
||||
toast.error(updateUser?.serverError);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Successfully uploaded user image!");
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
const handleImageUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (!file.type.includes("image")) {
|
||||
toast.error("File not an image");
|
||||
return;
|
||||
}
|
||||
submitImage.mutate(file);
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="relative ">
|
||||
<Avatar className="size-14 mr-3 ">
|
||||
<AvatarFallback>{user.name[0]}</AvatarFallback>
|
||||
{user.image ? <AvatarImage src={user.image} alt={`${user.name ?? "-"}'s profile picture`} /> : null}
|
||||
</Avatar>
|
||||
<div
|
||||
onClick={() => {
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = "image/*";
|
||||
// @ts-ignore
|
||||
fileInput.onchange = handleImageUpload;
|
||||
fileInput.click();
|
||||
}}
|
||||
className="cursor-pointer absolute inset-0 flex justify-center items-center opacity-0 transition-opacity hover:opacity-100 hover:bg-gray-500 hover:bg-opacity-50 rounded-full size-14"
|
||||
>
|
||||
<UploadIcon className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
"use server";
|
||||
import { db } from "@/db";
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import * as drizzleDb from "@/db";
|
||||
|
||||
|
||||
export const updateImageUserAction = userAction.schema(z.string()).action(async ({ parsedInput, ctx }) => {
|
||||
const [updatedUser] = await db.update(drizzleDb.schemas.user).set({ image: parsedInput }).where(eq(drizzleDb.schemas.user.id, ctx.user.id)).returning();
|
||||
|
||||
return {
|
||||
data: updatedUser,
|
||||
};
|
||||
});
|
||||
+1
-1
@@ -58,4 +58,4 @@ export const ButtonDeleteAccount = (props: ButtonDeleteAccountProps) => {
|
||||
|
||||
|
||||
);
|
||||
};
|
||||
};
|
||||
+1
-1
@@ -33,4 +33,4 @@ export const deleteUserAction = userAction.schema(z.string()).action(async ({ pa
|
||||
return {
|
||||
data: deletedUser,
|
||||
};
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
export type UserAvatarProps = {};
|
||||
|
||||
export const UserAvatar = async () => {
|
||||
const user = await currentUser();
|
||||
|
||||
if (!user) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Avatar className="size-6">
|
||||
<AvatarFallback>{user.name[0]}</AvatarFallback>
|
||||
{user.image ? <AvatarImage src={user.image} alt={`${user.name ?? "-"}'s profile picture`} /> : null}
|
||||
</Avatar>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,122 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } 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 { useRouter } from "next/navigation";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { UserSchema, UserType } from "@/components/wrappers/dashboard/profile2/user-form/user-form.schema";
|
||||
import { toast } from "sonner";
|
||||
import { updateUserAction } from "@/components/wrappers/dashboard/profile2/user-form/user-form.action";
|
||||
import {DataTable} from "@/components/wrappers/common/table/data-table";
|
||||
import {sessionsColumns} from "@/components/wrappers/dashboard/admin/users/sessions/table-columns";
|
||||
import {accountsColumns} from "@/components/wrappers/dashboard/admin/users/accounts/table-columns";
|
||||
import {Account, Session} from "better-auth";
|
||||
|
||||
export type UserFormProps = {
|
||||
defaultValues?: UserType;
|
||||
userId?: string;
|
||||
sessions: Session[];
|
||||
accounts?: Account[];
|
||||
};
|
||||
|
||||
export const UserForm = (props: UserFormProps) => {
|
||||
const isCreate = !Boolean(props.defaultValues);
|
||||
|
||||
const form = useZodForm({
|
||||
schema: UserSchema,
|
||||
defaultValues: props.defaultValues,
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: UserType) => {
|
||||
const updateUser = await updateUserAction({
|
||||
id: props.userId ?? "-",
|
||||
data: values,
|
||||
});
|
||||
|
||||
const data = updateUser?.data?.data;
|
||||
if (updateUser?.serverError || !data) {
|
||||
toast.error(updateUser?.serverError);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(`Profile updated successfully.`);
|
||||
router.refresh();
|
||||
router.push(`/dashboard/profile`);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-4 h-full py-4">
|
||||
<TooltipProvider>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Account</CardTitle>
|
||||
<CardDescription>Your informations</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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={"Your Name"} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={"exemple@portabase.com"} disabled {...field} value={field.value ?? ""} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button>{isCreate ? `` : `Save`}</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Active sessions</CardTitle>
|
||||
<CardDescription>Manage your active sessions</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable columns={sessionsColumns} data={props.sessions} enableSelect={false}/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Auth providers</CardTitle>
|
||||
<CardDescription>Manage your active auth providers</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable columns={accountsColumns} data={props.accounts ?? []} enableSelect={false}/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,47 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
||||
import { humanReadableDate } from "@/utils/date-formatting";
|
||||
import {ChartConfig, ChartTooltip} from "@/components/ui/chart";
|
||||
import {CartesianGrid, TooltipProps, XAxis, YAxis} from "recharts";
|
||||
import {formatDayOnly} from "@/utils/date-formatting";
|
||||
import {LineChartCustom} from "@/components/wrappers/dashboard/statistics/charts/line-chart";
|
||||
import {generateFakeEvolutionData} from "@/components/wrappers/dashboard/statistics/charts/fake-data";
|
||||
|
||||
type Data = {
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
export type evolutionLineChartProps = {
|
||||
data: Data[];
|
||||
type Payload = {
|
||||
date: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export function EvolutionLineChart(props: evolutionLineChartProps) {
|
||||
const { data } = props;
|
||||
type LineChartDatum = {
|
||||
date: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export type EvolutionLineChartProps = {
|
||||
data: Data[];
|
||||
|
||||
};
|
||||
|
||||
export function EvolutionLineChart(props: EvolutionLineChartProps) {
|
||||
const {data} = props;
|
||||
|
||||
// const fakeData = generateFakeEvolutionData(14, 2, 10)
|
||||
|
||||
// const cumulativeData = data.reduce(
|
||||
// (acc, backup) => {
|
||||
// const date = backup.createdAt.toISOString().split("T")[0]; // Format as YYYY-MM-DD
|
||||
//
|
||||
// // Increment count for the current date or initialize it
|
||||
// if (acc.length && acc[acc.length - 1].date === date) {
|
||||
// acc[acc.length - 1].count += 1;
|
||||
// } else {
|
||||
// const lastCount = acc.length ? acc[acc.length - 1].count : 0;
|
||||
// acc.push({ date, count: lastCount + 1 });
|
||||
// }
|
||||
//
|
||||
// return acc;
|
||||
// },
|
||||
// [] as { date: string; count: number }[]
|
||||
// );
|
||||
const dailyData = data
|
||||
.reduce((acc, backup) => {
|
||||
const date = backup.createdAt.toISOString().split("T")[0]; // Format: YYYY-MM-DD
|
||||
const date = backup.createdAt.toISOString().split("T")[0];
|
||||
|
||||
// Find if the date already exists in the accumulator
|
||||
const existing = acc.find(item => item.date === date);
|
||||
|
||||
if (existing) {
|
||||
existing.count += 1;
|
||||
} else {
|
||||
acc.push({ date, count: 1 });
|
||||
acc.push({date, count: 1});
|
||||
}
|
||||
|
||||
return acc;
|
||||
@@ -51,40 +49,66 @@ export function EvolutionLineChart(props: evolutionLineChartProps) {
|
||||
const chartConfig = {
|
||||
date: {
|
||||
label: "Date",
|
||||
color: "#2563eb",
|
||||
},
|
||||
count: {
|
||||
label: "Number of backups",
|
||||
color: "#60a5fa",
|
||||
label: "Number of backups ",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
return (
|
||||
<ChartContainer config={chartConfig}>
|
||||
<LineChart
|
||||
accessibilityLayer
|
||||
data={dailyData}
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value) => {
|
||||
return humanReadableDate(new Date(value)).split(" ")[0]
|
||||
}}
|
||||
/>
|
||||
<YAxis />
|
||||
<ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel />} />
|
||||
<Line dataKey="count" type="linear" stroke="#60a5fa" strokeWidth={2} dot={false} />
|
||||
|
||||
{/*<Line dataKey="count" type="linear" stroke="var(--color-desktop)" strokeWidth={2} dot={false} />*/}
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
<LineChartCustom<LineChartDatum>
|
||||
config={chartConfig}
|
||||
data={dailyData}
|
||||
title="Evolution of the number of backups"
|
||||
dataKey="count"
|
||||
>
|
||||
<CartesianGrid vertical={false}/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value) =>
|
||||
formatDayOnly(new Date(value))
|
||||
}
|
||||
/>
|
||||
|
||||
<YAxis tickLine={false}/>
|
||||
|
||||
<ChartTooltip
|
||||
cursor={{strokeDasharray: "3 3"}}
|
||||
content={<EvolutionTooltip/>}
|
||||
/>
|
||||
|
||||
</LineChartCustom>
|
||||
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function EvolutionTooltip({
|
||||
active,
|
||||
payload,
|
||||
}: TooltipProps<number, string>) {
|
||||
if (!active || !payload || payload.length === 0) return null;
|
||||
|
||||
const data = payload[0].payload as Payload;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-background px-3 py-2 shadow-md">
|
||||
<p className="text-sm font-medium">
|
||||
{formatDayOnly(new Date(data.date))}
|
||||
</p>
|
||||
|
||||
<div className="mt-1 flex items-center gap-2 text-sm">
|
||||
<span className="h-2 w-2 rounded-full bg-[#fc6504]"/>
|
||||
<span className="text-muted-foreground">Backups :</span>
|
||||
<span className="ml-auto font-semibold">
|
||||
{data.count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
type Data = {
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate fake backup events over a time range.
|
||||
*
|
||||
* @param days Number of days to generate
|
||||
* @param minPerDay Minimum events per day
|
||||
* @param maxPerDay Maximum events per day
|
||||
*/
|
||||
export function generateFakeEvolutionData(
|
||||
days: number = 30,
|
||||
minPerDay: number = 1,
|
||||
maxPerDay: number = 8
|
||||
): Data[] {
|
||||
const result: Data[] = [];
|
||||
const now = new Date();
|
||||
|
||||
for (let d = 0; d < days; d++) {
|
||||
const day = new Date(now);
|
||||
day.setDate(now.getDate() - d);
|
||||
|
||||
const events =
|
||||
Math.floor(Math.random() * (maxPerDay - minPerDay + 1)) + minPerDay;
|
||||
|
||||
for (let i = 0; i < events; i++) {
|
||||
const createdAt = new Date(day);
|
||||
createdAt.setHours(
|
||||
Math.floor(Math.random() * 24),
|
||||
Math.floor(Math.random() * 60),
|
||||
Math.floor(Math.random() * 60)
|
||||
);
|
||||
|
||||
result.push({ createdAt });
|
||||
}
|
||||
}
|
||||
|
||||
return result.sort(
|
||||
(a, b) => a.createdAt.getTime() - b.createdAt.getTime()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import {ChartConfig, ChartContainer} from "@/components/ui/chart";
|
||||
import {Line, LineChart} from "recharts";
|
||||
import {ReactNode, useState} from "react";
|
||||
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
|
||||
import {PlaceholderChart} from "@/components/wrappers/dashboard/statistics/charts/utils/placeholder";
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
|
||||
|
||||
type LineChartCustomProps<T> = {
|
||||
config: ChartConfig,
|
||||
data: T[],
|
||||
children: ReactNode,
|
||||
title: string,
|
||||
dataKey: string,
|
||||
margin?: { left: number; right: number }
|
||||
}
|
||||
|
||||
|
||||
export const LineChartCustom = <T extends { date: string }>({
|
||||
config,
|
||||
title,
|
||||
data,
|
||||
children,
|
||||
dataKey,
|
||||
margin
|
||||
}: LineChartCustomProps<T>) => {
|
||||
|
||||
|
||||
const [timeRange, setTimeRange] = useState("7d")
|
||||
const filteredData = data.filter((item) => {
|
||||
const date = new Date(item.date)
|
||||
const referenceDate = new Date()
|
||||
let daysToSubtract = 90
|
||||
if (timeRange === "30d") {
|
||||
daysToSubtract = 30
|
||||
} else if (timeRange === "7d") {
|
||||
daysToSubtract = 7
|
||||
}
|
||||
const startDate = new Date(referenceDate)
|
||||
startDate.setDate(startDate.getDate() - daysToSubtract)
|
||||
return date >= startDate
|
||||
})
|
||||
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="flex items-center gap-4 space-y-0 border-b py-0 sm:flex-row">
|
||||
<CardTitle className="text-sm md:text-lg">{title}</CardTitle>
|
||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||
<SelectTrigger
|
||||
className=" rounded-lg sm:ml-auto sm:flex"
|
||||
aria-label="Select a value"
|
||||
>
|
||||
<SelectValue placeholder="Last 3 months"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
<SelectItem value="7d" className="rounded-lg">
|
||||
Last 7 days
|
||||
</SelectItem>
|
||||
<SelectItem value="30d" className="rounded-lg">
|
||||
Last 30 days
|
||||
</SelectItem>
|
||||
<SelectItem value="90d" className="rounded-lg">
|
||||
Last 3 months
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6 md:pt-0 md:px-6 h-full w-full">
|
||||
|
||||
{data.length > 0 ? (
|
||||
<ChartContainer config={config}>
|
||||
<LineChart
|
||||
accessibilityLayer
|
||||
data={filteredData}
|
||||
margin={margin ? margin : {
|
||||
left: -35,
|
||||
right: 12,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
<Line
|
||||
dataKey={dataKey}
|
||||
type="linear"
|
||||
strokeWidth={2}
|
||||
stroke="#fc6504"
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<PlaceholderChart text="No data available"/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
|
||||
import { EStatusSchema } from "@/db/schema/types";
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
||||
import {ChartConfig, ChartTooltip} from "@/components/ui/chart";
|
||||
import {EStatusSchema} from "@/db/schema/types";
|
||||
import {LineChartCustom} from "@/components/wrappers/dashboard/statistics/charts/line-chart";
|
||||
import {CartesianGrid, TooltipProps, XAxis, YAxis} from "recharts";
|
||||
import {formatDayOnly} from "@/utils/date-formatting";
|
||||
|
||||
type Data = {
|
||||
createdAt: Date;
|
||||
@@ -10,31 +12,26 @@ type Data = {
|
||||
_count: number;
|
||||
};
|
||||
|
||||
type Payload = {
|
||||
date: string
|
||||
successRate: number
|
||||
}
|
||||
|
||||
export type percentageLineChartProps = {
|
||||
data: Data[];
|
||||
};
|
||||
|
||||
export function PercentageLineChart(props: percentageLineChartProps) {
|
||||
const { data } = props;
|
||||
const {data} = props;
|
||||
|
||||
const chartConfig = {
|
||||
date: {
|
||||
label: "Date",
|
||||
color: "#2563eb",
|
||||
},
|
||||
successRate: {
|
||||
label: "Success Rate",
|
||||
color: "#60a5fa",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const dailyStats = data.reduce(
|
||||
(acc, backup) => {
|
||||
const date = backup.createdAt.toISOString().split("T")[0]; // Format YYYY-MM-DD
|
||||
const date = backup.createdAt.toISOString().split("T")[0];
|
||||
const status = backup.status;
|
||||
|
||||
if (!acc[date]) {
|
||||
acc[date] = { success: 0, failed: 0, total: 0 };
|
||||
acc[date] = {success: 0, failed: 0, total: 0};
|
||||
}
|
||||
|
||||
acc[date][status === "success" ? "success" : "failed"] += backup._count;
|
||||
@@ -45,34 +42,81 @@ export function PercentageLineChart(props: percentageLineChartProps) {
|
||||
{} as Record<string, { success: number; failed: number; total: number }>
|
||||
);
|
||||
|
||||
// Format data for the chart
|
||||
const formattedData = Object.entries(dailyStats).map(([date, stats]) => ({
|
||||
date,
|
||||
successRate: (stats.success / stats.total) * 100,
|
||||
}));
|
||||
|
||||
|
||||
const chartConfig = {
|
||||
date: {
|
||||
label: "Date",
|
||||
},
|
||||
successRate: {
|
||||
label: "Success Rate",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
return (
|
||||
<ChartContainer config={chartConfig}>
|
||||
<LineChart accessibilityLayer data={formattedData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" />
|
||||
<YAxis domain={[0, 100]} tickFormatter={(tick) => `${tick}%`} />
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent />}
|
||||
cursor={false}
|
||||
defaultIndex={1}
|
||||
formatter={(value, name) => (
|
||||
<div className="flex min-w-[130px] items-center text-xs text-muted-foreground">
|
||||
{chartConfig[name as keyof typeof chartConfig]?.label || name}
|
||||
<div className="ml-auto flex items-baseline gap-0.5 font-mono font-medium tabular-nums text-foreground">
|
||||
{value}
|
||||
<span className="font-normal text-muted-foreground">%</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Line type="step" dataKey="successRate" stroke="#8884d8" strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
|
||||
<LineChartCustom<Payload>
|
||||
title="Success rate of backups"
|
||||
config={chartConfig}
|
||||
data={formattedData}
|
||||
dataKey="successRate"
|
||||
margin={{
|
||||
left: -15,
|
||||
right: 12,
|
||||
}}
|
||||
>
|
||||
{/*<CartesianGrid strokeDasharray="3 3" />*/}
|
||||
<CartesianGrid vertical={false}/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value) =>
|
||||
formatDayOnly(new Date(value))
|
||||
}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
domain={[0, 100]}
|
||||
tickFormatter={(tick) => `${tick}%`}
|
||||
/>
|
||||
<ChartTooltip
|
||||
defaultIndex={1}
|
||||
cursor={{strokeDasharray: "3 3"}}
|
||||
content={<PourcentTooltip/>}
|
||||
/>
|
||||
</LineChartCustom>
|
||||
);
|
||||
}
|
||||
|
||||
function PourcentTooltip({
|
||||
active,
|
||||
payload,
|
||||
}: TooltipProps<number, string>) {
|
||||
if (!active || !payload || payload.length === 0) return null;
|
||||
|
||||
const data = payload[0].payload as Payload;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-background px-3 py-2 shadow-md">
|
||||
<p className="text-sm font-medium">
|
||||
{formatDayOnly(new Date(data.date))}
|
||||
</p>
|
||||
|
||||
<div className="mt-1 flex items-center gap-2 text-sm">
|
||||
<span className="h-2 w-2 rounded-full bg-[#fc6504]"/>
|
||||
<span className="text-muted-foreground">Success Rate :</span>
|
||||
<span className="ml-auto font-semibold">
|
||||
{data.successRate} %
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
|
||||
|
||||
export const PlaceholderChart = ({text}: { text: string }) => (
|
||||
<div className="flex h-100 w-full items-center justify-center text-sm text-muted-foreground">{text}</div>
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "backups" ADD COLUMN "file_size" integer;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -113,6 +113,13 @@
|
||||
"when": 1766426190521,
|
||||
"tag": "0015_absurd_next_avengers",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1767363779637,
|
||||
"tag": "0016_broken_morgan_stark",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -126,6 +126,10 @@ export const projectRelations = relations(project, ({one}) => ({
|
||||
export const userSchema = createSelectSchema(user);
|
||||
export type User = z.infer<typeof userSchema>;
|
||||
|
||||
export const userThemeEnumSchema = createSelectSchema(userThemeEnum)
|
||||
export type UserThemeEnum = z.infer<typeof userThemeEnumSchema>;
|
||||
|
||||
|
||||
export const sessionSchema = createSelectSchema(session);
|
||||
export type Session = z.infer<typeof sessionSchema>;
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ export const backup = pgTable(
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
status: statusEnum("status").default("waiting").notNull(),
|
||||
file: text("file"),
|
||||
fileSize: integer("file_size"),
|
||||
databaseId: uuid("database_id")
|
||||
.notNull()
|
||||
.references(() => database.id, {onDelete: "cascade"}),
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import {SignUpUser} from "@/types/auth";
|
||||
import {hashPassword} from "better-auth/crypto";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {User, UserThemeEnum} from "@/db/schema/02_user";
|
||||
|
||||
|
||||
export async function createUserDb(data: SignUpUser): Promise<User> {
|
||||
const now = new Date();
|
||||
const hashedPassword = await hashPassword(data.password);
|
||||
const userId = crypto.randomUUID();
|
||||
|
||||
const [newUser] = await db.insert(drizzleDb.schemas.user).values({
|
||||
id: userId,
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
emailVerified: true,
|
||||
role: data.role,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
theme: data.theme as UserThemeEnum,
|
||||
}).returning();
|
||||
|
||||
await db.insert(drizzleDb.schemas.account).values({
|
||||
providerId: "credential",
|
||||
accountId: userId,
|
||||
userId: userId,
|
||||
password: hashedPassword,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
return newUser
|
||||
}
|
||||
@@ -31,7 +31,7 @@ import {cn} from "@/lib/utils";
|
||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
|
||||
import {MemberWithUser} from "@/db/schema/03_organization";
|
||||
import {formatLocalizedDate} from "@/utils/date-formatting";
|
||||
import {isImportedFilename} from "@/utils/text";
|
||||
import {formatBytes, isImportedFilename} from "@/utils/text";
|
||||
|
||||
|
||||
export function backupColumns(
|
||||
@@ -73,6 +73,14 @@ export function backupColumns(
|
||||
return isImported ? `${reference} (imported)` : `${reference}`
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "fileSize",
|
||||
header: "Size",
|
||||
cell: ({row}) => {
|
||||
console.log(row.original.fileSize)
|
||||
return formatBytes(row.getValue("fileSize"))
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "Created At",
|
||||
|
||||
@@ -11,7 +11,7 @@ import {count, eq} from "drizzle-orm";
|
||||
import {MemberWithUser, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
|
||||
import {sendEmail} from "@/lib/email/email-helper";
|
||||
import {render} from "@react-email/render";
|
||||
import {SUPPORTED_PROVIDERS} from "../../../portabase.config";
|
||||
import {AuthProviderConfig, SUPPORTED_PROVIDERS} from "../../../portabase.config";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
import EmailVerification from "@/components/emails/auth/email-verification";
|
||||
import EmailForgotPassword from "@/components/emails/auth/email-forgot-password";
|
||||
@@ -79,7 +79,8 @@ export const auth = betterAuth({
|
||||
});
|
||||
},
|
||||
},
|
||||
socialProviders: SUPPORTED_PROVIDERS.reduce((acc: any, provider: any) => {
|
||||
socialProviders: SUPPORTED_PROVIDERS.reduce((acc: any, provider: AuthProviderConfig) => {
|
||||
if (!provider.isActive) return acc;
|
||||
if (provider.id === "credential") return acc;
|
||||
if (provider.id === "google") {
|
||||
acc.google = {
|
||||
@@ -87,6 +88,12 @@ export const auth = betterAuth({
|
||||
clientSecret: env.AUTH_GOOGLE_SECRET! as string,
|
||||
};
|
||||
}
|
||||
if (provider.id === "github") {
|
||||
acc.github = {
|
||||
clientId: provider.credentials?.clientId,
|
||||
clientSecret: provider.credentials?.clientSecret,
|
||||
};
|
||||
}
|
||||
return acc;
|
||||
}, {}),
|
||||
account: {
|
||||
|
||||
+1
-1
@@ -4,8 +4,8 @@ export type SignUpUser = {
|
||||
email: string
|
||||
password: string
|
||||
callbackURL?: string
|
||||
role?: string
|
||||
theme: string
|
||||
isDefaultPassword: boolean
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -38,3 +38,12 @@ export function formatDateLastContact(lastContact: string | number | Date | null
|
||||
: "Never connected.";
|
||||
}
|
||||
|
||||
export function formatDayOnly(date: Date) {
|
||||
return new Intl.DateTimeFormat(LOCALE, {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
timeZone: TIMEZONE,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export function extractNameFromEmail(email: string): string {
|
||||
const localPart = email.split("@")[0];
|
||||
const nameParts = localPart
|
||||
.replace(/[_\.\-]/g, " ") // Replace underscores, dots, and hyphens with spaces
|
||||
.split(" ") // Split into parts
|
||||
.filter(Boolean); // Remove empty strings
|
||||
|
||||
return nameParts
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1)) // Capitalize each part
|
||||
.join(" ");
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export function generateValidPassword(length = 12) {
|
||||
const upper = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
const lower = 'abcdefghijklmnopqrstuvwxyz';
|
||||
const numbers = '0123456789';
|
||||
const special = '#?!@$%^&*-';
|
||||
const all = upper + lower + numbers + special;
|
||||
|
||||
const getRandomChar = (set: string) => set[Math.floor(Math.random() * set.length)];
|
||||
|
||||
const passwordChars = [
|
||||
getRandomChar(upper),
|
||||
getRandomChar(lower),
|
||||
getRandomChar(numbers),
|
||||
getRandomChar(special),
|
||||
];
|
||||
|
||||
for (let i = passwordChars.length; i < length; i++) {
|
||||
passwordChars.push(getRandomChar(all));
|
||||
}
|
||||
|
||||
for (let i = passwordChars.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[passwordChars[i], passwordChars[j]] = [passwordChars[j], passwordChars[i]];
|
||||
}
|
||||
|
||||
return passwordChars.join('');
|
||||
}
|
||||
@@ -15,4 +15,14 @@ export function isUUID(str: string) {
|
||||
|
||||
export function isImportedFilename(name: string): boolean {
|
||||
return name.startsWith("imported_");
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number | null, decimals = 2): string {
|
||||
if (!bytes) return "N/A";
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
|
||||
}
|
||||
Reference in New Issue
Block a user