Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
795a127998 | ||
|
|
fd73d383f9 | ||
|
|
ee285df7b5 | ||
|
|
7f7f8e642f | ||
|
|
d9997ff5e9 | ||
|
|
36adcb323a | ||
|
|
5302060f44 | ||
|
|
d19a46bee6 | ||
|
|
d504866aad | ||
|
|
601dae4909 | ||
|
|
08f2ad0521 | ||
|
|
3b356e33e7 | ||
|
|
57d9b8da1e | ||
|
|
86f50e5741 | ||
|
|
a6e52fd03d | ||
|
|
116229a29e | ||
|
|
428782e8a4 | ||
|
|
b8442803d9 | ||
|
|
82ab2c63d6 | ||
|
|
e953de0e06 | ||
|
|
f6d927f866 | ||
|
|
3773c4b068 | ||
|
|
bb0dbdadae | ||
|
|
dd8aec50eb | ||
|
|
7410aaea38 | ||
|
|
0f9bbbda3c | ||
|
|
25f9bc6e79 | ||
|
|
caf00b7877 | ||
|
|
9c0c462192 | ||
|
|
ef7570db76 | ||
|
|
e1bc1ec6c8 | ||
|
|
ca232250cc | ||
|
|
fde7688485 | ||
|
|
9458b12d69 | ||
|
|
5cf6cf075e | ||
|
|
8980e6d4a8 | ||
|
|
ac951378e8 | ||
|
|
934e635cdb | ||
|
|
d968aa9c16 | ||
|
|
eb471c7cdb | ||
|
|
e886bdd58d | ||
|
|
fe5d8bae1b | ||
|
|
aabdea06a9 | ||
|
|
37a3ace161 | ||
|
|
933ab7d68a | ||
|
|
f42cb37d67 | ||
|
|
7dfd9f02e8 | ||
|
|
411e09f452 | ||
|
|
f21149f42e | ||
|
|
099b6fb0e8 | ||
|
|
eb2e0ef468 | ||
|
|
4644ed2f41 | ||
|
|
cf8e5b7094 | ||
|
|
b0775e82ea | ||
|
|
f9b3461a44 | ||
|
|
c4ebc876bf | ||
|
|
a435a92d41 | ||
|
|
bcbd6bbea1 | ||
|
|
82559b49bd | ||
|
|
8c1b869004 | ||
|
|
c4a0692985 | ||
|
|
750f1cb145 | ||
|
|
b7d3487c53 | ||
|
|
529f157c6e | ||
|
|
c62be9854a | ||
|
|
9c1aa66b41 | ||
|
|
9894e2582d | ||
|
|
8a8ef81fce | ||
|
|
2fdd9f1296 | ||
|
|
5251e01011 | ||
|
|
84dab35f4b | ||
|
|
ae766dab2f | ||
|
|
027928c29f | ||
|
|
e3239639f3 | ||
|
|
7d6038e5f1 | ||
|
|
2a90d4933d |
@@ -5,9 +5,9 @@ NODE_ENV=production
|
||||
DATABASE_URL=postgresql://devuser:changeme@db:5432/devdb?schema=public
|
||||
|
||||
# Projet
|
||||
NEXT_PUBLIC_PROJECT_NAME="Portabase"
|
||||
NEXT_PUBLIC_PROJECT_DESCRIPTION="Portabase is a powerful database manager"
|
||||
NEXT_PUBLIC_PROJECT_URL=http://app.portabase.io
|
||||
PROJECT_NAME="Portabase"
|
||||
PROJECT_DESCRIPTION="Portabase is a powerful database manager"
|
||||
PROJECT_URL=http://app.portabase.io
|
||||
PROJECT_SECRET=
|
||||
|
||||
# SMTP (email)
|
||||
@@ -20,6 +20,7 @@ SMTP_FROM=
|
||||
# Google
|
||||
AUTH_GOOGLE_ID=
|
||||
AUTH_GOOGLE_SECRET=
|
||||
AUTH_GOOGLE_METHOD=
|
||||
|
||||
# S3
|
||||
S3_ENDPOINT=http://app.s3.portabase.io
|
||||
@@ -30,4 +31,7 @@ S3_PORT=9000
|
||||
S3_USE_SSL=true
|
||||
|
||||
# Storage Type (s3, local)
|
||||
STORAGE_TYPE=local
|
||||
STORAGE_TYPE=local
|
||||
|
||||
# Retention
|
||||
RETENTION_CRON="* * * * *"
|
||||
@@ -26,7 +26,7 @@ Please take a moment to review this guide. It will help you understand how to co
|
||||
|
||||
2. **Clone the repository**
|
||||
```bash
|
||||
git clone https://github.com/Soluce-Technologies/portabase
|
||||
git clone https://github.com/Portabase/portabase
|
||||
```
|
||||
|
||||
3. **Set up the development environment**
|
||||
@@ -7,9 +7,9 @@
|
||||
|
||||
We take security seriously and aim to support the following versions of the project with security updates:
|
||||
|
||||
| Version | Supported |
|
||||
|-----------|--------------------|
|
||||
| 1.x | ✅ Fully Supported |
|
||||
| Version | Supported |
|
||||
|---------|--------------------|
|
||||
| Latest | ✅ Fully Supported |
|
||||
|
||||
---
|
||||
|
||||
|
After Width: | Height: | Size: 35 KiB |
@@ -0,0 +1,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,69 +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
|
||||
|
||||
# Do not delete
|
||||
# - name: Build and push Docker image
|
||||
# id: push
|
||||
# uses: docker/build-push-action@v6
|
||||
# with:
|
||||
# context: .
|
||||
# file: ./docker/dockerfile/Dockerfile
|
||||
# push: true
|
||||
# tags: ${{ steps.meta.outputs.tags }}
|
||||
# labels: ${{ steps.meta.outputs.labels }}
|
||||
# target: prod
|
||||
|
||||
# - name: Generate artifact attestation
|
||||
# uses: actions/attest-build-provenance@v2
|
||||
# with:
|
||||
# subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
|
||||
# subject-digest: ${{ steps.push.outputs.digest }}
|
||||
# push-to-registry: true
|
||||
github_release:
|
||||
needs: docker_publish
|
||||
uses: ./.github/workflows/github.yml
|
||||
with:
|
||||
prerelease: false
|
||||
make_latest: true
|
||||
discord_title: "||@release-dashboard|| New release published"
|
||||
discord_color: 5814783
|
||||
discord_footer: "Portabase"
|
||||
secrets:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -24,5 +24,8 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0 # Fetch full history for gitleaks
|
||||
- uses: gitleaks/gitleaks-action@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
|
||||
with:
|
||||
config-path: .gitleaks.toml # Optional, if you have a custom config
|
||||
@@ -1,5 +1,5 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
.idea
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
|
||||
@@ -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.6
|
||||
date-released: "2026-01-01"
|
||||
@@ -1,222 +1,89 @@
|
||||
<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>
|
||||
<h3 align="center">Portabase</h3>
|
||||
|
||||
<p align="center">
|
||||
Easily backup and restore your database instances with Portabase
|
||||
<br />
|
||||
<a href="https://portabase.io"><strong>Explore the Docs »</strong></a>
|
||||
<br />
|
||||
<br />
|
||||
<a href="https://www.youtube.com/watch?v=D9uFrGxLc4s">View Demo</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>
|
||||
|
||||

|
||||
|
||||
|
||||
<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/Portabase/portabase)
|
||||
|
||||
[](https://www.postgresql.org/)
|
||||
[](https://www.mysql.com/)
|
||||
[](https://mariadb.org/)
|
||||
[](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">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>
|
||||
|
||||
## Installation
|
||||
|
||||
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)
|
||||
|
||||
**Ensure Docker is installed on your machine before getting started.**
|
||||
|
||||
|
||||
|
||||
## 📚 Table of Contents
|
||||
## Contributors
|
||||
|
||||
- [About The Project](#about-the-project)
|
||||
- [Built With](#built-with)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Installation](#installation)
|
||||
- [Usage](#usage)
|
||||
- [Roadmap](#roadmap)
|
||||
- [Contributing](#contributing)
|
||||
- [License](#license)
|
||||
- [Contact](#contact)
|
||||
- [Acknowledgments](#acknowledgments)
|
||||
[](https://github.com/Portabase/portabase/graphs/contributors)
|
||||
|
||||
---
|
||||
|
||||
## 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] (v15 with App Router)
|
||||
- [![Drizzle][Drizzle]][Drizzle-url]
|
||||
- [![ShadcnUI][ShadcnUI]][ShadcnUI-url]
|
||||
- BetterAuth
|
||||
- React Email
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Ensure Docker is installed on your machine before getting started.
|
||||
|
||||
### Installation
|
||||
|
||||
To run Portabase locally:
|
||||
|
||||
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 dashboard to manage database instances and backups. It supports:
|
||||
|
||||
- PostgreSQL (current)
|
||||
- MongoDB (coming soon)
|
||||
- MySQL (coming soon)
|
||||
|
||||
You can access the dashboard at `http://localhost:3000` after starting the project.
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ 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
|
||||
- [ ] MySQL
|
||||
|
||||
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
|
||||
NEXT_PUBLIC_PROJECT_NAME="Portabase"
|
||||
NEXT_PUBLIC_PROJECT_DESCRIPTION="Portabase is a powerful database manager"
|
||||
NEXT_PUBLIC_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=
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
[NextJS]: https://img.shields.io/badge/next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white
|
||||
|
||||
[BetterAuth]: https://img.shields.io/badge/Better%20Auth-FFF?logo=betterauth&logoColor=000&style=for-the-badge
|
||||
|
||||
[Drizzle]: https://img.shields.io/badge/Drizzle-111?style=for-the-badge&logo=Drizzle&logoColor=c5f74f
|
||||
|
||||
[ShadcnUI]: https://img.shields.io/badge/shadcn/ui-000000?style=for-the-badge&logo=shadcn/ui&logoColor=white
|
||||
|
||||
[NextJS-url]: https://nextjs.org/
|
||||
|
||||
[BetterAuth-url]: https://www.better-auth.com/
|
||||
|
||||
[Drizzle-url]: https://orm.drizzle.team/
|
||||
|
||||
[ShadcnUI-url]: https://ui.shadcn.com/
|
||||
|
||||
[Docker-url]: https://www.docker.com/
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import {CardContent, CardHeader} from "@/components/ui/card";
|
||||
|
||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
||||
import {ForgotPasswordForm} from "@/components/wrappers/auth/login/forgot-password-form/forgot-password-form";
|
||||
import {CardAuth} from "@/features/layout/card-auth";
|
||||
|
||||
export default async function RoutePage(props: { searchParams: Promise<{ callbackUrl: string | undefined }> }) {
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<CardAuth className="w-full">
|
||||
<CardHeader>
|
||||
<div className="grid gap-2 text-center mb-2">
|
||||
<h1 className="text-3xl font-bold">Reset password</h1>
|
||||
<p className="text-balance text-muted-foreground">Enter your email address and we'll send you a
|
||||
link to reset your password.</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ForgotPasswordForm/>
|
||||
</CardContent>
|
||||
</CardAuth>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {CardContent, CardHeader} from "@/components/ui/card";
|
||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
||||
import {GuardForm} from "@/components/wrappers/auth/guard/guard-form";
|
||||
import {cookies} from "next/headers";
|
||||
import {redirect} from "next/navigation";
|
||||
import {CardAuth} from "@/features/layout/card-auth";
|
||||
|
||||
export default async function GuardPage() {
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get("better-auth.two_factor")?.value;
|
||||
|
||||
if (!token) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<CardAuth className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<div className="grid gap-2 text-center mb-2">
|
||||
<h1 className="text-3xl font-bold">Two-factor verification</h1>
|
||||
<p className="text-balance text-muted-foreground">Please enter the verification code generated
|
||||
by your authentication app.</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<GuardForm/>
|
||||
</CardContent>
|
||||
</CardAuth>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +1,20 @@
|
||||
"use client"
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {LayoutAdmin} from "@/components/layout";
|
||||
import Image from "next/image";
|
||||
import {env} from "@/env.mjs";
|
||||
import {useTheme} from "next-themes";
|
||||
import {useSession} from "@/lib/auth/auth-client";
|
||||
import {useRouter} from "next/navigation";
|
||||
import React from "react";
|
||||
import {redirect} from "next/navigation";
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
import {AuthLogoSection} from "@/components/wrappers/auth/auth-logo-section";
|
||||
|
||||
export default async function Layout({children}: { children: React.ReactNode }) {
|
||||
|
||||
export default function Layout({children}: { children: React.ReactNode }) {
|
||||
const user = await currentUser();
|
||||
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const router = useRouter();
|
||||
const { data: session } = useSession();
|
||||
|
||||
if (session && session.user && !session.user.banned && session.user.role !== "pending") {
|
||||
router.replace("/dashboard/home");
|
||||
if (user && !user.banned && user.role !== "pending") {
|
||||
redirect("/dashboard/home");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const imageTheme = resolvedTheme === "dark" ? "/images/logo-white.png" : "/images/logo-black.png";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-1 flex-col justify-center py-12 sm:px-6 lg:px-8 ">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md flex items-center justify-center space-x-2">
|
||||
<img
|
||||
className="p-12 text-black dark:text-white"
|
||||
src={imageTheme}
|
||||
// loading="eager"
|
||||
alt="Logo"
|
||||
// width={1024}
|
||||
// height={1024}
|
||||
/>
|
||||
<span
|
||||
className="text-sm text-muted-foreground -ml-12 -mb-12">
|
||||
v{env.NEXT_PUBLIC_PROJECT_VERSION}
|
||||
</span>
|
||||
</div>
|
||||
<AuthLogoSection/>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
<footer className="py-4 text-center text-xs justify-items-end text-muted-foreground">
|
||||
|
||||
@@ -1,32 +1,52 @@
|
||||
"use client"
|
||||
|
||||
import {useEffect, useRef, useState} from "react";
|
||||
import {useSearchParams} from "next/navigation";
|
||||
|
||||
import {LoginForm} from "@/components/wrappers/auth/login/login-form/login-form";
|
||||
import {toast} from "sonner";
|
||||
import {Metadata} from "next";
|
||||
import {SUPPORTED_PROVIDERS} from "../../../portabase.config";
|
||||
import {SocialAuthButtons} from "@/components/wrappers/auth/social-buttons";
|
||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
||||
import {CardContent, CardHeader} from "@/components/ui/card";
|
||||
import Link from "next/link";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {CardAuth} from "@/features/layout/card-auth";
|
||||
|
||||
export default function SignInPage(props: {
|
||||
searchParams: Promise<{ callbackUrl: string | undefined }>
|
||||
}) {
|
||||
export const metadata: Metadata = {
|
||||
title: "Login",
|
||||
};
|
||||
|
||||
const [urlParams, setUrlParams] = useState<URLSearchParams>();
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
setUrlParams(urlParams);
|
||||
const error = urlParams.get("error");
|
||||
console.log(urlParams.get("redirect"));
|
||||
if (error?.includes("pending")) {
|
||||
toast.error("Your account is not active.");
|
||||
urlParams.delete("error");
|
||||
window.history.replaceState({}, document.title, window.location.pathname + "?" + urlParams.toString());
|
||||
}
|
||||
}, []);
|
||||
export default async function SignInPage() {
|
||||
|
||||
return (
|
||||
<div className="mx-auto grid w-full gap-6">
|
||||
<LoginForm/>
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<CardAuth className="w-full">
|
||||
<CardHeader>
|
||||
<div className="grid gap-2 text-center mb-2">
|
||||
<h1 className="text-3xl font-bold">Login</h1>
|
||||
<p className="text-balance text-muted-foreground">Fill your login informations</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<LoginForm/>
|
||||
|
||||
{SUPPORTED_PROVIDERS.filter((p) => !p.isManual && p.isActive).length > 0 && (
|
||||
<>
|
||||
<div className="relative my-4 flex items-center justify-center overflow-hidden">
|
||||
<Separator/>
|
||||
<div className="px-2 text-center text-sm">OR</div>
|
||||
<Separator/>
|
||||
</div>
|
||||
<SocialAuthButtons providers={SUPPORTED_PROVIDERS}/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-4 text-center text-sm">
|
||||
Don't have an account ?{" "}
|
||||
<Link href="/register" className="underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</CardAuth>
|
||||
</TooltipProvider>
|
||||
|
||||
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {RegisterForm} from "@/components/wrappers/auth/register/register-form/register-form";
|
||||
import {Metadata} from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Register",
|
||||
};
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { ResetPasswordForm } from "@/components/wrappers/auth/login/reset-password-form/reset-password-form";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth/auth";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@radix-ui/react-avatar";
|
||||
|
||||
export default async function RoutePage(props: { searchParams: Promise<{ token: string | undefined }> }) {
|
||||
|
||||
const { token } = await props.searchParams;
|
||||
|
||||
if (!token) {
|
||||
return redirect(`/login?error=invalid_or_expired_token`);
|
||||
}
|
||||
|
||||
const verification = await (await auth.$context).internalAdapter.findVerificationValue(`reset-password:${token}`);
|
||||
|
||||
if (!verification || verification.expiresAt < new Date()) {
|
||||
return redirect(`/login?error=invalid_or_expired_token`);
|
||||
}
|
||||
|
||||
const user = await (await auth.$context).internalAdapter.findUserById(verification.value);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Card className="w-full max-w-md shadow-lg">
|
||||
<CardHeader className="space-y-4">
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Set a new password</h1>
|
||||
<p className="text-sm text-muted-foreground text-balance">Please enter your new password below.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center space-y-2 text-center">
|
||||
<Avatar className="relative flex h-16 w-16 shrink-0 overflow-hidden rounded-full border">
|
||||
<AvatarImage src={user!.image ?? ""} alt={user!.name} className="aspect-square h-full w-full object-cover" />
|
||||
<AvatarFallback className="flex h-full w-full items-center justify-center rounded-full bg-muted text-2xl font-medium text-muted-foreground">
|
||||
{user!.name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="font-semibold text-lg">{user!.name}</div>
|
||||
<div className="text-sm text-muted-foreground">{user!.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<ResetPasswordForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import {notFound} from "next/navigation";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page} from "@/features/layout/page";
|
||||
import {OrganizationManagement} from "@/components/wrappers/dashboard/admin/organizations/organization/organization-management";
|
||||
import {buildOrganizationWithMembers} from "@/utils/common";
|
||||
import {isUUID} from "@/utils/text";
|
||||
import {user} from "@/db/schema/02_user";
|
||||
import {invitation} from "@/db/schema/05_invitation";
|
||||
import {member} from "@/db/schema/04_member";
|
||||
import {organization} from "@/db/schema/03_organization";
|
||||
import {user as drizzleUser} from "@/db/schema/02_user";
|
||||
|
||||
|
||||
export default async function RoutePage(props: PageParams<{ organizationId: string }>) {
|
||||
const {organizationId} = await props.params;
|
||||
|
||||
if (!organizationId) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
if (!isUUID(organizationId)) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const users = await db.select().from(drizzleUser);
|
||||
|
||||
const organizationData = await db
|
||||
.select({organization, member, user, invitation})
|
||||
.from(organization)
|
||||
.leftJoin(member, eq(drizzleDb.schemas.organization.id, member.organizationId))
|
||||
.leftJoin(invitation, eq(drizzleDb.schemas.invitation.id, invitation.organizationId))
|
||||
.leftJoin(user, eq(drizzleDb.schemas.member.userId, user.id))
|
||||
.where(eq(organization.id, organizationId));
|
||||
|
||||
const formattedData = buildOrganizationWithMembers(organizationData);
|
||||
|
||||
if (!formattedData) return notFound();
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<OrganizationManagement organization={formattedData} users={users}/>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {db} from "@/db";
|
||||
import {
|
||||
AdminOrganizationAddModal
|
||||
} from "@/components/wrappers/dashboard/admin/organizations/organization/admin-organization-add-modal";
|
||||
import {AdminOrganizationList} from "@/components/wrappers/dashboard/admin/organizations/organization/admin-orgnization-list";
|
||||
import {isNull} from "drizzle-orm";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
const organizations = await db.query.organization.findMany({
|
||||
where: (fields) => isNull(fields.deletedAt),
|
||||
with: {
|
||||
members: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader className="flex flex-col">
|
||||
<div className="flex justify-between">
|
||||
<PageTitle className="mb-3">Active organizations</PageTitle>
|
||||
<PageActions>
|
||||
<AdminOrganizationAddModal/>
|
||||
</PageActions>
|
||||
</div>
|
||||
</PageHeader>
|
||||
<PageContent className="flex flex-col gap-5">
|
||||
<AdminOrganizationList organizations={organizations}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {AdminTabs} from "@/components/wrappers/dashboard/admin/admin-tabs";
|
||||
import {db} from "@/db";
|
||||
import {isNull} from "drizzle-orm";
|
||||
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
const users = await db.query.user.findMany({
|
||||
where: (fields) => isNull(fields.deletedAt),
|
||||
with: {
|
||||
accounts: true
|
||||
}
|
||||
});
|
||||
|
||||
const settings = await db.query.setting.findFirst({
|
||||
where: (fields, {eq}) => eq(fields.name, "system"),
|
||||
});
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle>Administration Panel</PageTitle>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<AdminTabs settings={settings!} users={users}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {db} from "@/db";
|
||||
import {SettingsEmailSection} from "@/components/wrappers/dashboard/admin/settings/email/settings-email-section";
|
||||
import {notFound} from "next/navigation";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
const settings = await db.query.setting.findFirst({
|
||||
where: (fields, {eq}) => eq(fields.name, "system"),
|
||||
});
|
||||
|
||||
if (!settings) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader className="flex flex-col">
|
||||
<div className="flex justify-between">
|
||||
<PageTitle className="mb-3">System email</PageTitle>
|
||||
</div>
|
||||
</PageHeader>
|
||||
<PageContent className="flex flex-col gap-5">
|
||||
<SettingsEmailSection settings={settings}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {SettingsStorageSection} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage-section";
|
||||
import {db} from "@/db";
|
||||
import {notFound} from "next/navigation";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
const settings = await db.query.setting.findFirst({
|
||||
where: (fields, {eq}) => eq(fields.name, "system"),
|
||||
});
|
||||
|
||||
if (!settings) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader className="flex flex-col">
|
||||
<div className="flex justify-between">
|
||||
<PageTitle className="mb-3">System storage</PageTitle>
|
||||
</div>
|
||||
</PageHeader>
|
||||
<PageContent className="flex flex-col gap-5">
|
||||
<SettingsStorageSection settings={settings}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {db} from "@/db";
|
||||
import {desc, isNull} from "drizzle-orm";
|
||||
import {AdminUserList} from "@/components/wrappers/dashboard/admin/users/admin-user-list";
|
||||
import {AdminUserAddModal} from "@/components/wrappers/dashboard/admin/users/admin-user-add-modal";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
const users = await db.query.user.findMany({
|
||||
where: (fields) => isNull(fields.deletedAt),
|
||||
with: {
|
||||
accounts: true
|
||||
},
|
||||
orderBy: (fields) => desc(fields.createdAt),
|
||||
|
||||
});
|
||||
const organizations = await db.query.organization.findMany({
|
||||
with: {
|
||||
members: true,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<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">
|
||||
<AdminUserList users={users}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,23 +2,16 @@ import Link from "next/link";
|
||||
import {GearIcon} from "@radix-ui/react-icons";
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageActions, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
||||
import {formatDateLastContact} from "@/utils/date-formatting";
|
||||
import {buttonVariants} from "@/components/ui/button";
|
||||
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
|
||||
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
|
||||
import {DatabaseCard} from "@/components/wrappers/dashboard/projects/project-card/project-database-card";
|
||||
import {AgentCardKey} from "@/components/wrappers/dashboard/agent/agent-card-key/agent-card-key";
|
||||
import { db } from "@/db";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {notFound} from "next/navigation";
|
||||
import {
|
||||
ButtonDeleteProject
|
||||
} from "@/components/wrappers/dashboard/projects/button-delete-project/button-delete-project";
|
||||
import {ButtonDeleteAgent} from "@/components/wrappers/dashboard/agent/button-delete-agent/button-delete-agent";
|
||||
import {capitalizeFirstLetter} from "@/utils/text";
|
||||
import {Server} from "lucide-react";
|
||||
|
||||
import {generateEdgeKey} from "@/utils/edge_key";
|
||||
import {getServerUrl} from "@/utils/get-server-url";
|
||||
import {AgentContentPage} from "@/components/wrappers/dashboard/agent/agent-content";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{ agentId: string }>) {
|
||||
|
||||
@@ -31,11 +24,11 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
if (!agent) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const edgeKey = await generateEdgeKey(getServerUrl(), agent.id);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
@@ -48,45 +41,18 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
|
||||
</Link>
|
||||
</PageTitle>
|
||||
<PageActions className="justify-between">
|
||||
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"} />
|
||||
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"}/>
|
||||
</PageActions>
|
||||
</div>
|
||||
<PageDescription className="mt-5 sm:mt-0">{agent.description}</PageDescription>
|
||||
<PageContent className="flex flex-col w-full h-full">
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between gap-8 mb-6">
|
||||
|
||||
<Card className="w-full sm:w-auto flex-1">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Databases</CardTitle>
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{agent.databases.length}</div>
|
||||
<p className="text-xs text-muted-foreground">Databases linked to this agent</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="w-full sm:w-auto flex-1">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Last contact</CardTitle>
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{formatDateLastContact(agent.lastContact)}</div>
|
||||
<p className="text-xs text-muted-foreground">Last contact with agent</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
<Card className="w-full sm:w-auto flex-1 mb-4">
|
||||
<CardHeader className="font-bold text-xl">
|
||||
Edge Key
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AgentCardKey agent={agent}/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<CardsWithPagination cardsPerPage={2} data={agent.databases} cardItem={DatabaseCard}/>
|
||||
{agent.description && (
|
||||
<PageDescription className="mt-5 sm:mt-0">{agent.description}</PageDescription>
|
||||
)}
|
||||
<PageContent className="flex flex-col w-full h-full justify-between gap-6">
|
||||
<AgentContentPage
|
||||
agent={agent}
|
||||
edgeKey={edgeKey}
|
||||
/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { PageParams } from "@/types/next";
|
||||
import { Page, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
|
||||
import { AgentForm } from "@/components/wrappers/dashboard/agent/agent-form/agent-form";
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {AgentForm} from "@/components/wrappers/dashboard/agent/agent-form/agent-form";
|
||||
import {Metadata} from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Agent",
|
||||
};
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
return (
|
||||
@@ -9,7 +14,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
<PageTitle>Create new agent</PageTitle>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<AgentForm />
|
||||
<AgentForm/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -1,25 +1,32 @@
|
||||
import { PageParams } from "@/types/next";
|
||||
import { AgentCard } from "@/components/wrappers/dashboard/agent/agent-card/agent-card";
|
||||
import { CardsWithPagination } from "@/components/wrappers/common/cards-with-pagination";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {PageParams} from "@/types/next";
|
||||
import {AgentCard} from "@/components/wrappers/dashboard/agent/agent-card/agent-card";
|
||||
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { Page, PageActions, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
|
||||
import { notFound } from "next/navigation";
|
||||
import { db } from "@/db";
|
||||
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {notFound} from "next/navigation";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
|
||||
import {and, eq, not} from "drizzle-orm";
|
||||
import {Plus} from "lucide-react";
|
||||
import {cn} from "@/lib/utils";
|
||||
import {desc, eq, not} from "drizzle-orm";
|
||||
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
|
||||
export const dynamic = "force-dynamic";
|
||||
import {Metadata} from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Agents",
|
||||
};
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
const agents = await db.query.agent.findMany({
|
||||
where: not(eq(drizzleDb.schemas.agent.isArchived, true))
|
||||
where: not(eq(drizzleDb.schemas.agent.isArchived, true)),
|
||||
with: {
|
||||
databases: true
|
||||
},
|
||||
orderBy: (fields) => desc(fields.createdAt),
|
||||
});
|
||||
|
||||
console.log(agents);
|
||||
|
||||
|
||||
if (!agents) {
|
||||
notFound();
|
||||
@@ -39,7 +46,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
{agents.length > 0 ? (
|
||||
<CardsWithPagination data={agents} cardItem={AgentCard} cardsPerPage={4} numberOfColumns={1} />
|
||||
<CardsWithPagination data={agents} cardItem={AgentCard} cardsPerPage={4} numberOfColumns={1}/>
|
||||
) : (
|
||||
<EmptyStatePlaceholder
|
||||
url={"/dashboard/agents/new"}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {Metadata} from "next";
|
||||
import {
|
||||
NotificationChannelsSection
|
||||
} from "@/components/wrappers/dashboard/admin/notifications/channels/notification-channels-section";
|
||||
import {db} from "@/db";
|
||||
import {notificationChannel, NotificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
||||
import {NotifierAddEditModal} from "@/components/wrappers/dashboard/common/notifier/notifier-add-edit-modal";
|
||||
import {desc, isNull} from "drizzle-orm";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Notification Channels",
|
||||
};
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
const notificationChannels = await db.query.notificationChannel.findMany({
|
||||
with: {
|
||||
organizations: true
|
||||
},
|
||||
orderBy: desc(notificationChannel.createdAt)
|
||||
}) as NotificationChannelWith[]
|
||||
|
||||
const organizations = await db.query.organization.findMany({
|
||||
where: (fields) => isNull(fields.deletedAt),
|
||||
with: {
|
||||
members: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle>Notification channels</PageTitle>
|
||||
<PageActions>
|
||||
<NotifierAddEditModal adminView={false}/>
|
||||
</PageActions>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<NotificationChannelsSection organizations={organizations} notificationChannels={notificationChannels}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {Metadata} from "next";
|
||||
import {NotificationLogsList} from "@/components/wrappers/dashboard/admin/notifications/logs/notification-logs-list";
|
||||
import {notFound} from "next/navigation";
|
||||
import {getNotificationHistory} from "@/db/services/notification-log";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Activity logs",
|
||||
};
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
const notificationLogs = await getNotificationHistory()
|
||||
|
||||
if (!notificationLogs) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle>Activity logs</PageTitle>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<NotificationLogsList notificationLogs={notificationLogs} />
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {notFound} from "next/navigation";
|
||||
import {RestoreForm} from "@/components/wrappers/dashboard/database/restore-form";
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
|
||||
import {db} from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
|
||||
|
||||
export default async function RoutePage(
|
||||
props: PageParams<{
|
||||
databaseId: string;
|
||||
}>
|
||||
) {
|
||||
const {databaseId} = await props.params;
|
||||
|
||||
const user = await currentUser();
|
||||
if (!user) notFound();
|
||||
|
||||
const [dbToRestore] = await db.select().from(drizzleDb.schemas.database).where(eq(drizzleDb.schemas.database.id, databaseId));
|
||||
|
||||
if (!dbToRestore) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const dbsOfSameType = await db.select().from(drizzleDb.schemas.database).where(eq(drizzleDb.schemas.database.dbms!, dbToRestore.dbms!));
|
||||
const successfulBackups = await db.select().from(drizzleDb.schemas.backup).where(eq(drizzleDb.schemas.backup.status, "success"));
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle>Restore {dbToRestore.name}</PageTitle>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<RestoreForm databaseToRestore={dbToRestore} databases={dbsOfSameType} backups={successfulBackups}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,20 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {notFound, redirect} from "next/navigation";
|
||||
import {Page, PageActions, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
||||
import {Page, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
||||
import {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
|
||||
import {DatabaseTabs} from "@/components/wrappers/dashboard/projects/database/database-tabs";
|
||||
import {DatabaseKpi} from "@/components/wrappers/dashboard/projects/database/database-kpi";
|
||||
import {EditButton} from "@/components/wrappers/dashboard/database/edit-button/edit-button";
|
||||
import {CronButton} from "@/components/wrappers/dashboard/database/cron-button/cron-button";
|
||||
|
||||
import {db} from "@/db";
|
||||
import {eq, and, inArray} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {getOrganizationProjectDatabases} from "@/lib/services";
|
||||
import {getOrganization} from "@/lib/auth/auth";
|
||||
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
||||
import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet";
|
||||
import {capitalizeFirstLetter} from "@/utils/text";
|
||||
import {AlertPolicyModal} from "@/components/wrappers/dashboard/database/alert-policy/alert-policy-modal";
|
||||
import {getOrganizationChannels} from "@/db/services/notification-channel";
|
||||
import {ImportModal} from "@/components/wrappers/dashboard/database/import/import-modal";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{
|
||||
projectId: string;
|
||||
@@ -22,8 +23,9 @@ export default async function RoutePage(props: PageParams<{
|
||||
const {projectId, databaseId} = await props.params;
|
||||
|
||||
const organization = await getOrganization({});
|
||||
const activeMember = await getActiveMember()
|
||||
|
||||
if (!organization) {
|
||||
if (!organization || !activeMember) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
@@ -36,7 +38,8 @@ export default async function RoutePage(props: PageParams<{
|
||||
where: and(inArray(drizzleDb.schemas.backup.id, databasesProject.ids ?? []), eq(drizzleDb.schemas.database.id, databaseId), eq(drizzleDb.schemas.database.projectId, projectId)),
|
||||
with: {
|
||||
project: true,
|
||||
retentionPolicy: true
|
||||
retentionPolicy: true,
|
||||
alertPolicies: true
|
||||
}
|
||||
});
|
||||
|
||||
@@ -81,27 +84,40 @@ export default async function RoutePage(props: PageParams<{
|
||||
notFound();
|
||||
}
|
||||
|
||||
const organizationChannels = await getOrganizationChannels(organization.id);
|
||||
const activeOrganizationChannels = organizationChannels.filter(channel => channel.enabled);
|
||||
|
||||
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
|
||||
|
||||
const isMember = activeMember?.role === "member";
|
||||
|
||||
return (
|
||||
<Page>
|
||||
|
||||
<div className="justify-between gap-2 sm:flex">
|
||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full">
|
||||
<div className=" w-full md:w-fit">
|
||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||
<div className="min-w-full md:min-w-fit ">
|
||||
{capitalizeFirstLetter(dbItem.name)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 md:justify-between w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Do not delete*/}
|
||||
{/*<EditButton/>*/}
|
||||
<RetentionPolicySheet database={dbItem}/>
|
||||
<CronButton database={dbItem}/>
|
||||
{!isMember && (
|
||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Do not delete*/}
|
||||
{/*<EditButton/>*/}
|
||||
<RetentionPolicySheet database={dbItem}/>
|
||||
<CronButton database={dbItem}/>
|
||||
<AlertPolicyModal database={dbItem} notificationChannels={activeOrganizationChannels}
|
||||
organizationId={organization.id}/>
|
||||
<ImportModal database={dbItem}/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<BackupButton disable={isAlreadyBackup} databaseId={databaseId}/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<BackupButton disable={isAlreadyBackup} databaseId={databaseId}/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageTitle>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{dbItem.description && (
|
||||
@@ -110,7 +126,8 @@ export default async function RoutePage(props: PageParams<{
|
||||
<PageContent className="flex flex-col w-full h-full">
|
||||
<DatabaseKpi successRate={successRate} database={dbItem} availableBackups={availableBackups}
|
||||
totalBackups={totalBackups}/>
|
||||
<DatabaseTabs settings={settings} database={dbItem} isAlreadyRestore={isAlreadyRestore}
|
||||
<DatabaseTabs activeMember={activeMember} settings={settings} database={dbItem}
|
||||
isAlreadyRestore={isAlreadyRestore}
|
||||
backups={backups}
|
||||
restorations={restorations}/>
|
||||
</PageContent>
|
||||
|
||||
@@ -12,7 +12,7 @@ import {notFound, redirect} from "next/navigation";
|
||||
|
||||
import {db} from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {getOrganization} from "@/lib/auth/auth";
|
||||
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {capitalizeFirstLetter} from "@/utils/text";
|
||||
|
||||
@@ -24,6 +24,8 @@ export default async function RoutePage(props: PageParams<{
|
||||
} = await props.params;
|
||||
|
||||
const organization = await getOrganization({});
|
||||
const activeMember = await getActiveMember()
|
||||
|
||||
if (!organization) {
|
||||
notFound();
|
||||
}
|
||||
@@ -48,23 +50,27 @@ export default async function RoutePage(props: PageParams<{
|
||||
redirect("/dashboard/projects");
|
||||
}
|
||||
|
||||
const isMember = activeMember?.role === "member";
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="justify-between gap-2 sm:flex">
|
||||
<PageTitle className="flex items-center">
|
||||
{capitalizeFirstLetter(proj.name)}
|
||||
<Link className={buttonVariants({variant: "outline"})} href={`/dashboard/projects/${proj.id}/edit`}>
|
||||
<GearIcon className="w-7 h-7"/>
|
||||
</Link>
|
||||
{!isMember && (
|
||||
<Link className={buttonVariants({variant: "outline"})}
|
||||
href={`/dashboard/projects/${proj.id}/edit`}>
|
||||
<GearIcon className="w-7 h-7"/>
|
||||
</Link>
|
||||
)}
|
||||
</PageTitle>
|
||||
<PageActions className="justify-between">
|
||||
<ButtonDeleteProject projectId={projectId} text={"Delete Project"}/>
|
||||
</PageActions>
|
||||
{!isMember && (
|
||||
<PageActions className="justify-between">
|
||||
<ButtonDeleteProject projectId={projectId} text={"Delete Project"}/>
|
||||
</PageActions>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PageDescription>The list of associated databases</PageDescription>
|
||||
|
||||
<PageContent className="flex flex-col w-full h-full">
|
||||
{proj.databases.length > 0 ? (
|
||||
<CardsWithPagination
|
||||
|
||||
@@ -8,11 +8,12 @@ import {getOrganization} from "@/lib/auth/auth";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {DatabaseWith} from "@/db/schema/07_database";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{ }>) {
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
const organization = await getOrganization({});
|
||||
|
||||
if (!organization ) {
|
||||
if (!organization) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,18 @@ import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/
|
||||
import {ProjectCard} from "@/components/wrappers/dashboard/projects/project-card/project-card";
|
||||
import {db} from "@/db";
|
||||
import {notFound} from "next/navigation";
|
||||
import {getOrganization} from "@/lib/auth/auth";
|
||||
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
||||
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
|
||||
import {Metadata} from "next";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{ }>) {
|
||||
export const metadata: Metadata = {
|
||||
title: "Projects",
|
||||
};
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
const organization = await getOrganization({});
|
||||
const activeMember = await getActiveMember()
|
||||
|
||||
if (!organization) {
|
||||
notFound();
|
||||
@@ -28,12 +34,14 @@ export default async function RoutePage(props: PageParams<{ }>) {
|
||||
databases: true,
|
||||
},
|
||||
});
|
||||
const isMember = activeMember?.role === "member";
|
||||
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle>Projects</PageTitle>
|
||||
{projects.length > 0 && (
|
||||
{(projects.length > 0 && !isMember) && (
|
||||
<PageActions>
|
||||
<Link href={`/dashboard/projects/new`}>
|
||||
<Button>+ Create Project</Button>
|
||||
@@ -44,13 +52,17 @@ export default async function RoutePage(props: PageParams<{ }>) {
|
||||
|
||||
<PageContent>
|
||||
{projects.length > 0 ? (
|
||||
<CardsWithPagination organizationSlug={organization.slug} data={projects} cardItem={ProjectCard}
|
||||
cardsPerPage={4} numberOfColumns={1}/>
|
||||
) : (
|
||||
<EmptyStatePlaceholder
|
||||
url={"/dashboard/projects/new"}
|
||||
text={"Create new Project"}
|
||||
<CardsWithPagination
|
||||
organizationSlug={organization.slug}
|
||||
data={projects}
|
||||
cardItem={ProjectCard}
|
||||
cardsPerPage={4}
|
||||
numberOfColumns={1}
|
||||
/>
|
||||
) : isMember ? (
|
||||
<EmptyStatePlaceholder text="No project available"/>
|
||||
) : (
|
||||
<EmptyStatePlaceholder url="/dashboard/projects/new" text="Create new Project"/>
|
||||
)}
|
||||
</PageContent>
|
||||
</Page>
|
||||
|
||||
@@ -18,7 +18,6 @@ export default async function RoutePage(props: PageParams<{
|
||||
where: (fields) => isNull(fields.deletedAt)
|
||||
});
|
||||
|
||||
|
||||
if (!user || !users || !organization || organization.slug == "default") {
|
||||
notFound();
|
||||
}
|
||||
|
||||
@@ -7,46 +7,51 @@ import {
|
||||
DeleteOrganizationButton
|
||||
} from "@/components/wrappers/dashboard/organization/delete-organization/delete-organization-button";
|
||||
import {EditButtonSettings} from "@/components/wrappers/dashboard/settings/edit-button-settings/edit-button-settings";
|
||||
import {
|
||||
SettingsOrganizationMembersTable
|
||||
} from "@/components/wrappers/dashboard/settings/settings-organization-members-table";
|
||||
import {Metadata} from "next";
|
||||
import {OrganizationTabs} from "@/components/wrappers/dashboard/organization/tabs/organization-tabs";
|
||||
import {getOrganizationChannels} from "@/db/services/notification-channel";
|
||||
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Settings",
|
||||
};
|
||||
|
||||
export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
||||
const organization = await getOrganization({});
|
||||
const user = await currentUser();
|
||||
const activeMember = await getActiveMember()
|
||||
|
||||
if (!organization) {
|
||||
if (!organization || !activeMember) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const isMember = activeMember?.role === "member";
|
||||
const notificationChannels = await getOrganizationChannels(organization.id)
|
||||
|
||||
const permissions = computeOrganizationPermissions(activeMember);
|
||||
|
||||
if (isMember) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle className="flex items-center">
|
||||
Organization settings
|
||||
{!isMember && organization.slug !== "default" && (
|
||||
{permissions.canManageSettings && organization.slug !== "default" && (
|
||||
<EditButtonSettings/>
|
||||
)}
|
||||
</PageTitle>
|
||||
|
||||
<PageActions>
|
||||
{!isMember && organization.slug !== "default" && (
|
||||
{permissions.canManageDangerZone && organization.slug !== "default" && (
|
||||
<DeleteOrganizationButton organizationSlug={organization.slug}/>
|
||||
)}
|
||||
</PageActions>
|
||||
</PageHeader>
|
||||
{/*<PageDescription>*/}
|
||||
{/* Manage your organization settings.*/}
|
||||
{/*</PageDescription>*/}
|
||||
<PageContent>
|
||||
<SettingsOrganizationMembersTable organization={organization}/>
|
||||
<OrganizationTabs
|
||||
activeMember={activeMember}
|
||||
organization={organization}
|
||||
notificationChannels={notificationChannels}
|
||||
/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
)
|
||||
|
||||
@@ -8,7 +8,12 @@ import {db} from "@/db";
|
||||
import {and, asc, count, eq, inArray} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {getOrganization} from "@/lib/auth/auth";
|
||||
import {DatabaseBackup, Folder, RefreshCcw} from "lucide-react";
|
||||
import {Building2, DatabaseBackup, Folder, RefreshCcw} from "lucide-react";
|
||||
import {Metadata} from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Statistics",
|
||||
};
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
const organization = await getOrganization({});
|
||||
@@ -45,79 +50,6 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
});
|
||||
|
||||
|
||||
|
||||
// const tomorrow = new Date();
|
||||
// tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
//
|
||||
// const before = new Date();
|
||||
// before.setDate(before.getDate() - 1);
|
||||
//
|
||||
// const backupsEvolution = [
|
||||
// {
|
||||
// id: '22e84aa4-228c-45b3-82ec-846a639cd509',
|
||||
// createdAt: new Date()
|
||||
// },
|
||||
// {
|
||||
// id: '2c114dc3-1fa6-4ef1-972c-9765c65e9331',
|
||||
// createdAt: new Date()
|
||||
// },
|
||||
// {
|
||||
// id: '2c114dc3-1fa6-4ef1-972c-9765c65e9331',
|
||||
// createdAt: new Date()
|
||||
// },
|
||||
// {
|
||||
// id: '2c114dc3-1fa6-4ef1-972c-9765c65e9331',
|
||||
// createdAt: new Date()
|
||||
// },
|
||||
// {
|
||||
// id: '2c114dc3-1fa6-4ef1-972c-9765c65e9331',
|
||||
// createdAt: new Date(before)
|
||||
// },
|
||||
// {
|
||||
// id: '6a6106fe-7f45-48eb-a56f-0a1e734126a1',
|
||||
// createdAt: new Date(before)
|
||||
// },
|
||||
// {
|
||||
// id: 'a529d790-502e-4609-ad37-9b1c00c73477',
|
||||
// createdAt: new Date()
|
||||
// },
|
||||
// {
|
||||
// id: 'a8c105a8-3e29-423e-b7dd-d3218092cde1',
|
||||
// createdAt: new Date()
|
||||
// },
|
||||
// {
|
||||
// id: 'd33aaf4f-8525-4490-addb-12e3c8650d6d',
|
||||
// createdAt: new Date()
|
||||
// },
|
||||
// {
|
||||
// id: 'f1d5a4e2-1c33-41c4-932b-02456c2a6f1d',
|
||||
// createdAt: new Date(tomorrow)
|
||||
// },
|
||||
// {
|
||||
// id: 'f1d5a4e2-1c33-41c4-932b-02456c2a6f1d',
|
||||
// createdAt: new Date(tomorrow)
|
||||
// },
|
||||
// {
|
||||
// id: 'e88a588a-2353-4470-9976-8c3eb2ffc88d',
|
||||
// createdAt: new Date()
|
||||
// },
|
||||
// {
|
||||
// id: 'ee11441e-4b41-4c1b-9d91-929565b4204a',
|
||||
// createdAt: new Date()
|
||||
// },
|
||||
//
|
||||
// // Entries with tomorrow's date
|
||||
// {
|
||||
// id: 'f1d5a4e2-1c33-41c4-932b-02456c2a6f1d',
|
||||
// createdAt: new Date(tomorrow)
|
||||
// },
|
||||
// {
|
||||
// id: 'c0a8323d-9241-4896-9e64-01e905c24e51',
|
||||
// createdAt: new Date(tomorrow)
|
||||
// }
|
||||
// ];
|
||||
|
||||
|
||||
const backupsRate = await db
|
||||
.select({
|
||||
createdAt: drizzleDb.schemas.backup.createdAt,
|
||||
@@ -130,7 +62,6 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
.orderBy(drizzleDb.schemas.backup.createdAt);
|
||||
|
||||
|
||||
|
||||
const restorationsCountResult = await db
|
||||
.select({
|
||||
count: count(),
|
||||
@@ -139,62 +70,64 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
.where(inArray(drizzleDb.schemas.restoration.databaseId, databaseIds));
|
||||
|
||||
|
||||
|
||||
const restorationsCount = restorationsCountResult[0]?.count ?? 0;
|
||||
const projectsCount = projects.length;
|
||||
const backupsEvolutionCount = backupsEvolution.length;
|
||||
|
||||
|
||||
const sortedBackupsEvolution = backupsEvolution.sort(
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
);
|
||||
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle>Statistics</PageTitle>
|
||||
<PageTitle>Statistics Overview</PageTitle>
|
||||
</PageHeader>
|
||||
|
||||
<PageContent className="flex flex-col gap-y-4">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<Card className="w-full flex-1">
|
||||
<CardHeader className="flex items-center gap-2">
|
||||
<Folder className="w-5 h-5 text-muted-foreground" />
|
||||
<CardTitle>Projects</CardTitle>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card className="w-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">Projects</CardTitle>
|
||||
<Building2 className="h-4 w-4 text-muted-foreground"/>
|
||||
</CardHeader>
|
||||
<CardContent className="text-3xl font-bold">{projectsCount}</CardContent>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{projectsCount}</div>
|
||||
<p className="text-xs text-muted-foreground">Active projects in this organization</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="w-full flex-1">
|
||||
<CardHeader className="flex items-center gap-2">
|
||||
<DatabaseBackup className="w-5 h-5 text-muted-foreground" />
|
||||
<CardTitle>Backups</CardTitle>
|
||||
|
||||
<Card className="w-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">Backups</CardTitle>
|
||||
<DatabaseBackup className="h-4 w-4 text-muted-foreground"/>
|
||||
</CardHeader>
|
||||
<CardContent className="text-3xl font-bold">{backupsEvolutionCount}</CardContent>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{backupsEvolutionCount}</div>
|
||||
<p className="text-xs text-muted-foreground">Total backups executed across all databases</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="w-full flex-1">
|
||||
<CardHeader className="flex items-center gap-2">
|
||||
<RefreshCcw className="w-5 h-5 text-muted-foreground" />
|
||||
<CardTitle>Restorations</CardTitle>
|
||||
|
||||
<Card className="w-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">Restorations</CardTitle>
|
||||
<RefreshCcw className="h-4 w-4 text-muted-foreground"/>
|
||||
</CardHeader>
|
||||
<CardContent className="text-3xl font-bold">{restorationsCount}</CardContent>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{restorationsCount}</div>
|
||||
<p className="text-xs text-muted-foreground">Total restoration operations performed</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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>
|
||||
<EvolutionLineChart data={sortedBackupsEvolution}/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Success rate of backups</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PercentageLineChart data={backupsRate}/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<EvolutionLineChart
|
||||
data={sortedBackupsEvolution}
|
||||
/>
|
||||
|
||||
<PercentageLineChart
|
||||
data={backupsRate}/>
|
||||
</div>
|
||||
</PageContent>
|
||||
</Page>
|
||||
|
||||
@@ -8,6 +8,11 @@ import {db} from "@/db";
|
||||
import {asc, inArray} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {listOrganizations} from "@/lib/auth/auth";
|
||||
import {Metadata} from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Home",
|
||||
};
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import React from "react";
|
||||
import React, {ReactNode} from "react";
|
||||
import {redirect} from "next/navigation";
|
||||
|
||||
import {SidebarInset, SidebarProvider} from "@/components/ui/sidebar";
|
||||
import {AppSidebar} from "@/components/wrappers/dashboard/common/sidebar/app-sidebar";
|
||||
import {Header} from "@/features/layout/Header";
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
import {ThemeMetaUpdater} from "@/features/browser/theme-meta-updater";
|
||||
|
||||
export default async function Layout({children}: { children: React.ReactNode }) {
|
||||
export default async function Layout({children}: { children: ReactNode }) {
|
||||
const user = await currentUser();
|
||||
if (!user) redirect("/login");
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarProvider>
|
||||
<div className="flex flex-col lg:flex-row w-full">
|
||||
<AppSidebar/>
|
||||
<SidebarInset>
|
||||
<Header/>
|
||||
<main className="h-full">{children}</main>
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</>
|
||||
<SidebarProvider>
|
||||
<div className="flex flex-col lg:flex-row w-full">
|
||||
<ThemeMetaUpdater/>
|
||||
<AppSidebar/>
|
||||
<SidebarInset>
|
||||
<Header/>
|
||||
<main className="h-full">{children}</main>
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageTitle} from "@/features/layout/page";
|
||||
import {notFound} from "next/navigation";
|
||||
import {UserForm} from "@/components/wrappers/dashboard/profile/user-form/user-form";
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
import {ButtonDeleteAccount} from "@/components/wrappers/dashboard/profile/button-delete-account/button-delete-account";
|
||||
import {AvatarWithUpload} from "@/components/wrappers/dashboard/profile/avatar/avatar-with-upload";
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
import {getAccounts, getSessions} from "@/lib/auth/auth";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
const user = await currentUser();
|
||||
if (!user) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
if (user.role !== "user" && user.role !== "admin" && user.role !== "superadmin") {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const sessions = await getSessions();
|
||||
const accounts = await getAccounts();
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="justify-between gap-2 sm:flex">
|
||||
<PageTitle className="flex items-center">
|
||||
<AvatarWithUpload
|
||||
user={{
|
||||
...user,
|
||||
image: user.image ?? null,
|
||||
role: user.role ?? null,
|
||||
banned: user.banned ?? null,
|
||||
banReason: user.banReason ?? null,
|
||||
banExpires: user.banExpires ?? null,
|
||||
deletedAt: user.deletedAt ? new Date(user.deletedAt) : null,
|
||||
}}
|
||||
/>
|
||||
{user.name}
|
||||
<Badge className="ml-3 hidden lg:block">{user.role}</Badge>
|
||||
</PageTitle>
|
||||
{/*<PageActions className="mt-2 hidden sm:block">*/}
|
||||
{/* <ButtonDeleteAccount text="Delete my account"/>*/}
|
||||
{/*</PageActions>*/}
|
||||
</div>
|
||||
<PageContent >
|
||||
<UserForm
|
||||
userId={user.id}
|
||||
sessions={sessions} accounts={accounts}
|
||||
defaultValues={{
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role ?? undefined,
|
||||
}}
|
||||
/>
|
||||
{/*<div className="mt-4 sm:hidden ">*/}
|
||||
{/* <ButtonDeleteAccount text="Delete my account"/>*/}
|
||||
{/*</div>*/}
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +1,56 @@
|
||||
import fs from "node:fs";
|
||||
import forge from "node-forge";
|
||||
import {EventPayload} from "@/features/notifications/types";
|
||||
import {dispatchNotification} from "@/features/notifications/dispatch";
|
||||
import {Database, DatabaseWith} from "@/db/schema/07_database";
|
||||
|
||||
|
||||
export async function decryptedDump(file: File, aesKeyHex: string, ivHex: string, fileExtension: string): Promise<File> {
|
||||
const privateKeyPem = fs.readFileSync("private/keys/server_private.pem", "utf8");
|
||||
const privateKey = forge.pki.privateKeyFromPem(privateKeyPem);
|
||||
|
||||
// Decrypt AES key with RSA-OAEP
|
||||
const encryptedAesKey = forge.util.hexToBytes(aesKeyHex);
|
||||
const aesKey = privateKey.decrypt(encryptedAesKey, "RSA-OAEP", {
|
||||
md: forge.md.sha256.create(),
|
||||
mgf1: {md: forge.md.sha256.create()},
|
||||
});
|
||||
|
||||
// Read encrypted file content
|
||||
const encryptedBuffer = Buffer.from(await file.arrayBuffer());
|
||||
const iv = forge.util.hexToBytes(ivHex);
|
||||
|
||||
// AES decryption
|
||||
const decipher = forge.cipher.createDecipher("AES-CBC", aesKey);
|
||||
decipher.start({iv});
|
||||
decipher.update(forge.util.createBuffer(encryptedBuffer.toString("binary")));
|
||||
const success = decipher.finish();
|
||||
|
||||
if (!success) {
|
||||
throw new Error("Decryption failed");
|
||||
}
|
||||
|
||||
const decryptedBytes = decipher.output.getBytes();
|
||||
const decryptedBuffer = Buffer.from(decryptedBytes, "binary");
|
||||
|
||||
// Return a File so you can use file.arrayBuffer() later
|
||||
return new File(
|
||||
[decryptedBuffer],
|
||||
file.name.replace(/\.enc$/, fileExtension),
|
||||
{type: "application/octet-stream"}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export function getFileExtension(dbType: string) {
|
||||
switch (dbType) {
|
||||
case "postgresql":
|
||||
return ".dump";
|
||||
case "mysql":
|
||||
return ".sql";
|
||||
default:
|
||||
return ".dump";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,12 +3,14 @@ import {isUuidv4} from "@/utils/verify-uuid";
|
||||
import {uploadLocalPrivate, uploadS3Private} from "@/features/upload/private/upload.action";
|
||||
import {v4 as uuidv4} from "uuid";
|
||||
import {eventEmitter} from "../../../events/route";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {db} from "@/db";
|
||||
import {Backup} from "@/db/schema/07_database";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {env} from "@/env.mjs";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
import {decryptedDump, getFileExtension} from "./helpers";
|
||||
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
@@ -27,6 +29,8 @@ export async function POST(
|
||||
|
||||
const agentId = (await params).agentId;
|
||||
const formData = await request.formData();
|
||||
const aesKeyHex = formData.get("aes_key") as string;
|
||||
const ivHex = formData.get("iv") as string;
|
||||
const generatedId = formData.get("generatedId") as string | null;
|
||||
const method = formData.get("method") as string | null;
|
||||
|
||||
@@ -51,7 +55,8 @@ export async function POST(
|
||||
const database = await db.query.database.findFirst({
|
||||
where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId),
|
||||
with: {
|
||||
project: true
|
||||
project: true,
|
||||
alertPolicies: true
|
||||
}
|
||||
});
|
||||
|
||||
@@ -101,6 +106,11 @@ 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});
|
||||
}
|
||||
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json(
|
||||
@@ -108,11 +118,13 @@ export async function POST(
|
||||
{status: 400}
|
||||
);
|
||||
}
|
||||
|
||||
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}.dump`;
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
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.");
|
||||
@@ -138,12 +150,15 @@ export async function POST(
|
||||
.update(drizzleDb.schemas.backup)
|
||||
.set(withUpdatedAt({
|
||||
file: fileName,
|
||||
fileSize: fileSizeBytes,
|
||||
status: 'success',
|
||||
}))
|
||||
.where(eq(drizzleDb.schemas.backup.id, backup.id));
|
||||
|
||||
eventEmitter.emit('modification', {update: true});
|
||||
|
||||
await sendNotificationsBackupRestore(database, "success_backup");
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: "Backup successfully uploaded",
|
||||
@@ -159,6 +174,9 @@ export async function POST(
|
||||
|
||||
eventEmitter.emit('modification', {update: true});
|
||||
|
||||
await sendNotificationsBackupRestore(database, "error_backup");
|
||||
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: "Backup successfully updated with status failed",
|
||||
@@ -173,4 +191,5 @@ export async function POST(
|
||||
{status: 500}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import {eventEmitter} from "../../../events/route";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {db} from "@/db";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
|
||||
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
|
||||
|
||||
export type BodyResultRestore = {
|
||||
generatedId: string
|
||||
@@ -13,7 +13,6 @@ export type BodyResultRestore = {
|
||||
type RestorationStatus = 'waiting' | 'ongoing' | 'failed' | 'success';
|
||||
|
||||
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{params}: { params: Promise<{ agentId: string }> }
|
||||
@@ -27,7 +26,6 @@ export async function POST(
|
||||
|
||||
console.log(body)
|
||||
|
||||
|
||||
if (!isUuidv4(body.generatedId)) {
|
||||
return NextResponse.json(
|
||||
{error: "generatedId is not a valid uuid"},
|
||||
@@ -43,8 +41,10 @@ export async function POST(
|
||||
}
|
||||
|
||||
const database = await db.query.database.findFirst({
|
||||
where: eq(drizzleDb.schemas.database.agentDatabaseId, body.generatedId)
|
||||
|
||||
where: eq(drizzleDb.schemas.database.agentDatabaseId, body.generatedId),
|
||||
with: {
|
||||
alertPolicies: true
|
||||
}
|
||||
})
|
||||
|
||||
if (!database) {
|
||||
@@ -59,11 +59,15 @@ export async function POST(
|
||||
return NextResponse.json({error: "Unable to fin the corresponding restoration"}, {status: 404})
|
||||
}
|
||||
|
||||
|
||||
await db
|
||||
.update(drizzleDb.schemas.restoration)
|
||||
.set({ status: body.status as RestorationStatus })
|
||||
.set({status: body.status as RestorationStatus})
|
||||
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
||||
|
||||
|
||||
await sendNotificationsBackupRestore(database, body.status == "error" ? "error_restore" : "success_restore");
|
||||
|
||||
const response = {
|
||||
message: true,
|
||||
details: "Restoration successfully updated"
|
||||
@@ -71,10 +75,7 @@ export async function POST(
|
||||
|
||||
eventEmitter.emit('modification', {update: true});
|
||||
|
||||
|
||||
return Response.json(response, {status: 200})
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in POST handler:', error);
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -7,7 +7,7 @@ import {Database} from "@/db/schema/07_database";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {db as dbClient} from "@/db";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types";
|
||||
import {EDbmsSchema} from "@/db/schema/types";
|
||||
import {ServerActionResult} from "@/types/action-type";
|
||||
import {SafeActionResult} from "next-safe-action";
|
||||
import {ZodString} from "zod";
|
||||
@@ -64,10 +64,13 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
}
|
||||
} else {
|
||||
|
||||
|
||||
const [databaseUpdated] = await dbClient
|
||||
.update(drizzleDb.schemas.database)
|
||||
.set({lastContact: lastContact})
|
||||
.set(withUpdatedAt({
|
||||
name: db.name,
|
||||
agentId: agent.id,
|
||||
lastContact: lastContact
|
||||
}))
|
||||
.where(eq(drizzleDb.schemas.database.id, existingDatabase.id))
|
||||
.returning();
|
||||
|
||||
@@ -124,13 +127,12 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
try {
|
||||
|
||||
if (settings.storage == "local") {
|
||||
data = await getFileUrlPresignedLocal(fileName!)
|
||||
data = await getFileUrlPresignedLocal({fileName: fileName!})
|
||||
} else if (settings.storage == "s3") {
|
||||
|
||||
data = await getFileUrlPreSignedS3Action(`backups/${backupToRestore?.database.project?.slug}/${fileName}`);
|
||||
}
|
||||
|
||||
|
||||
if (data?.data?.success) {
|
||||
urlBackup = data.data.value ?? "";
|
||||
} else {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {db} from "@/db";
|
||||
import {EDbmsSchema} from "@/db/schema/types";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {isUuidv4} from "@/utils/verify-uuid";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
|
||||
export type databaseAgent = {
|
||||
name: string,
|
||||
@@ -15,12 +16,13 @@ export type databaseAgent = {
|
||||
}
|
||||
|
||||
export type Body = {
|
||||
version: string,
|
||||
databases: databaseAgent[]
|
||||
}
|
||||
|
||||
// Function to test the get file url presigned local
|
||||
export async function GET(request: Request) {
|
||||
const url = await getFileUrlPresignedLocal("d4a7fa35-2506-4d01-a612-a8ef2e2cc1c5.dump")
|
||||
const url = await getFileUrlPresignedLocal({fileName: "d4a7fa35-2506-4d01-a612-a8ef2e2cc1c5.dump"})
|
||||
return Response.json({
|
||||
message: url
|
||||
})
|
||||
@@ -60,7 +62,10 @@ export async function POST(
|
||||
|
||||
await db
|
||||
.update(drizzleDb.schemas.agent)
|
||||
.set({lastContact: lastContact})
|
||||
.set(withUpdatedAt({
|
||||
version: body.version,
|
||||
lastContact: lastContact
|
||||
}))
|
||||
.where(eq(drizzleDb.schemas.agent.id, agentId));
|
||||
|
||||
eventEmitter.emit('modification', {update: true});
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import {NextResponse} from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
PROJECT_URL: process.env.PROJECT_URL,
|
||||
PROJECT_NAME: process.env.PROJECT_NAME,
|
||||
PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION,
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,19 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import {EventEmitter} from 'events';
|
||||
import {auth} from "@/lib/auth/auth";
|
||||
import {headers} from "next/headers";
|
||||
import {NextResponse} from "next/server";
|
||||
|
||||
export const eventEmitter = new EventEmitter();
|
||||
|
||||
export async function GET(request: Request) {
|
||||
console.log('GET request received');
|
||||
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({error: "Unauthorized"}, {status: 403});
|
||||
}
|
||||
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
@@ -37,13 +47,13 @@ export async function GET(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
console.log('POST request received');
|
||||
const data = await request.json();
|
||||
console.log('Data received:', data);
|
||||
|
||||
// Emit the event to all connected clients
|
||||
eventEmitter.emit('modification', data);
|
||||
|
||||
return new Response('Event sent', { status: 200 });
|
||||
}
|
||||
// export async function POST(request: Request) {
|
||||
// console.log('POST request received');
|
||||
// const data = await request.json();
|
||||
// console.log('Data received:', data);
|
||||
//
|
||||
// // Emit the event to all connected clients
|
||||
// eventEmitter.emit('modification', data);
|
||||
//
|
||||
// return new Response('Event sent', {status: 200});
|
||||
// }
|
||||
@@ -12,16 +12,16 @@ export async function GET(
|
||||
const expires = searchParams.get('expires');
|
||||
const fileName = (await params).fileName
|
||||
|
||||
const privateLocalDir = "private/uploads/files/";
|
||||
const filePath = path.join(privateLocalDir, fileName);
|
||||
const uploadsDir = "private/uploads/files/";
|
||||
const uploadPath = path.join(uploadsDir, fileName);
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return NextResponse.json(
|
||||
{error: 'File not found'},
|
||||
{status: 404}
|
||||
);
|
||||
let filePath = null;
|
||||
if (fs.existsSync(uploadPath)) {
|
||||
filePath = uploadPath;
|
||||
} else {
|
||||
return NextResponse.json({error: "File not found"}, {status: 404})
|
||||
}
|
||||
|
||||
const expectedToken = crypto.createHash('sha256').update(`${fileName}${expires}`).digest('hex');
|
||||
@@ -31,8 +31,8 @@ export async function GET(
|
||||
{status: 403}
|
||||
);
|
||||
}
|
||||
//@ts-ignore
|
||||
const expiresAt = parseInt(expires, 10);
|
||||
|
||||
const expiresAt = parseInt(expires!, 10);
|
||||
if (Date.now() > expiresAt) {
|
||||
return NextResponse.json(
|
||||
{error: 'Signed token expired'},
|
||||
|
||||
@@ -1,38 +1,103 @@
|
||||
import {NextResponse} from "next/server";
|
||||
import {auth} from "@/lib/auth/auth";
|
||||
import {headers} from "next/headers";
|
||||
import {checkFileExistsInBucket, getObjectFromClient} from "@/utils/s3-file-management";
|
||||
import {env} from "@/env.mjs";
|
||||
import * as stream from "node:stream";
|
||||
import path from "path";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import fs from "fs/promises";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
function nodeStreamToWebStream(nodeStream: stream.Readable) {
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
nodeStream.on("data", chunk => controller.enqueue(chunk));
|
||||
nodeStream.on("end", () => controller.close());
|
||||
nodeStream.on("error", err => controller.error(err));
|
||||
},
|
||||
cancel() {
|
||||
nodeStream.destroy();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const privateS3ImageDir = "images/";
|
||||
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
req: Request,
|
||||
{params}: { params: Promise<{ fileName: string }> }
|
||||
) {
|
||||
const fileName = (await params).fileName;
|
||||
if (!fileName) return NextResponse.json({error: "Missing file parameter"}, {status: 400});
|
||||
|
||||
const session = await auth.api.getSession({headers: await headers()});
|
||||
if (!session) return NextResponse.json({error: "Unauthorized"}, {status: 403});
|
||||
|
||||
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.");
|
||||
|
||||
const storageType = settings.storage; // "local" or "s3"
|
||||
const ext = fileName.split(".").pop()?.toLowerCase();
|
||||
const contentType =
|
||||
ext === "png"
|
||||
? "image/png"
|
||||
: ext === "jpg" || ext === "jpeg"
|
||||
? "image/jpeg"
|
||||
: ext === "gif"
|
||||
? "image/gif"
|
||||
: ext === "webp"
|
||||
? "image/webp"
|
||||
: "application/octet-stream";
|
||||
|
||||
try {
|
||||
const fileName = (await params).fileName;
|
||||
if (storageType === "local") {
|
||||
const filePath = path.join(process.cwd(), "private/uploads/images", fileName);
|
||||
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
const file = await fs.readFile(filePath);
|
||||
|
||||
console.log("fileName", fileName);
|
||||
|
||||
const filePath = path.join(process.cwd(), "private/uploads/images", fileName);
|
||||
|
||||
// Check if the file exists
|
||||
try {
|
||||
await fs.access(filePath); // Ensures the file exists
|
||||
} catch {
|
||||
return NextResponse.json({ error: "File not found" }, { status: 404 });
|
||||
return new NextResponse(file, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Disposition": `inline; filename="${fileName}"`,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// if not found locally, fallback to S3
|
||||
}
|
||||
}
|
||||
|
||||
// Read the file
|
||||
const fileContent = await fs.readFile(filePath); // Returns a Buffer
|
||||
const exists = await checkFileExistsInBucket({
|
||||
bucketName: env.S3_BUCKET_NAME!,
|
||||
fileName: `${privateS3ImageDir}${fileName}`,
|
||||
});
|
||||
if (!exists) return NextResponse.json({error: "File not found"}, {status: 404});
|
||||
|
||||
return new NextResponse(fileContent, {
|
||||
const nodeStream = await getObjectFromClient({
|
||||
bucketName: env.S3_BUCKET_NAME!,
|
||||
fileName: `${privateS3ImageDir}${fileName}`,
|
||||
});
|
||||
const webStream = nodeStreamToWebStream(nodeStream);
|
||||
|
||||
return new NextResponse(webStream, {
|
||||
headers: {
|
||||
"Content-Disposition": `attachment; filename="${fileName}"`,
|
||||
"Content-Type": "application/octet-stream", // Adjust MIME type as needed
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Disposition": `inline; filename="${fileName}"`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error reading file:", error);
|
||||
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
} catch (err) {
|
||||
console.error("Error streaming image:", err);
|
||||
return NextResponse.json({error: "Error fetching file"}, {status: 500});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export type BodyInit = {
|
||||
initialize: boolean;
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body: BodyInit = await request.json();
|
||||
|
||||
console.log(body);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: "Initialization successfully done!",
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error in POST initialization:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 3.9 KiB |
@@ -1,31 +1,37 @@
|
||||
import React from "react";
|
||||
import type {Metadata} from "next";
|
||||
import {Inter} from "next/font/google";
|
||||
import "./globals.css";
|
||||
import {Providers} from "./providers";
|
||||
import {cn} from "@/lib/utils";
|
||||
import {ConsoleSilencer} from "@/components/wrappers/common/console-silencer";
|
||||
import {inter} from "@/fonts/fonts";
|
||||
|
||||
const inter = Inter({subsets: ["latin"]});
|
||||
const title = process.env.PROJECT_NAME ?? "App Title";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: process.env.NEXT_PUBLIC_PROJECT_NAME ?? "App Title",
|
||||
description: process.env.NEXT_PUBLIC_PROJECT_DESCRIPTION ?? undefined,
|
||||
title: {
|
||||
default: title,
|
||||
template: `%s - ${title}`
|
||||
},
|
||||
description: process.env.PROJECT_DESCRIPTION ?? undefined,
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<meta name="apple-mobile-web-app-title" content="Portabase"/>
|
||||
<meta name="apple-mobile-web-app-title" content={title}/>
|
||||
</head>
|
||||
<body className={cn(inter.className, "h-full")}>
|
||||
<ConsoleSilencer/>
|
||||
<Providers>{children}</Providers>
|
||||
<Providers>
|
||||
{children}
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import BackButton from "@/components/wrappers/common/button/back-button";
|
||||
|
||||
export default async function NotFound() {
|
||||
return(
|
||||
@@ -7,6 +8,7 @@ export default async function NotFound() {
|
||||
<h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">Not found</h1>
|
||||
<p className="leading-7 [&:not(:first-child)]:mt-6">The content you are trying to view is not available.</p>
|
||||
</div>
|
||||
<BackButton>Go home</BackButton>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { PropsWithChildren, Suspense } from "react";
|
||||
import { ThemeProvider } from "@/features/theme/theme-provider";
|
||||
import {PropsWithChildren, Suspense} from "react";
|
||||
import {ThemeProvider} from "@/features/theme/theme-provider";
|
||||
|
||||
import {Toaster} from "@/components/ui/sonner";
|
||||
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
|
||||
import {ThemeMetaUpdaterRoot} from "@/features/browser/theme-meta-updater-root";
|
||||
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
export type ProviderProps = PropsWithChildren<{}>;
|
||||
const queryClient = new QueryClient();
|
||||
@@ -12,9 +14,14 @@ const queryClient = new QueryClient();
|
||||
export const Providers = (props: ProviderProps) => {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
>
|
||||
<ThemeMetaUpdaterRoot/>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Toaster />
|
||||
<Toaster/>
|
||||
{props.children}
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -10,11 +10,15 @@
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "radix",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {
|
||||
"@reui": "https://reui.io/r/{name}.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,22 +3,22 @@ name: portabase-prod
|
||||
services:
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/dockerfile/Dockerfile
|
||||
target: prod
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: docker/dockerfile/Dockerfile
|
||||
# target: prod
|
||||
image: solucetechnologies/portabase:1.1.4-rc.2
|
||||
ports:
|
||||
- '8887:80'
|
||||
environment:
|
||||
TZ: "Europe/Paris"
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
container_name: portabase-app-prod
|
||||
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
ports:
|
||||
@@ -35,20 +35,5 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
|
||||
s3:
|
||||
image: docker.io/bitnami/minio:latest
|
||||
ports:
|
||||
- '9000:9000'
|
||||
- '9001:9001'
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
environment:
|
||||
- MINIO_ROOT_USER=${S3_ACCESS_KEY}
|
||||
- MINIO_ROOT_PASSWORD=${S3_SECRET_KEY}
|
||||
- MINIO_DEFAULT_BUCKETS=${S3_BUCKET_NAME}
|
||||
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
minio_data:
|
||||
|
||||
@@ -1,73 +1,21 @@
|
||||
name: portabase-dev
|
||||
|
||||
services:
|
||||
# app:
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: docker/dockerfile/Dockerfile
|
||||
# target: dev
|
||||
# ports:
|
||||
# - "8887:8887"
|
||||
# environment:
|
||||
# - TIME_ZONE="Europe/Paris"
|
||||
# - NODE_ENV=development
|
||||
# depends_on:
|
||||
# db:
|
||||
# condition: service_healthy
|
||||
# volumes:
|
||||
# - .:/app
|
||||
# - /app/node_modules
|
||||
# container_name: portabase-app
|
||||
#
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
environment:
|
||||
- POSTGRES_DB=devdb
|
||||
- POSTGRES_USER=devuser
|
||||
- POSTGRES_PASSWORD=changeme
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U devuser -d devdb"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# db-pgadmin:
|
||||
# image: dpage/pgadmin4
|
||||
# environment:
|
||||
# PGADMIN_DEFAULT_EMAIL: "devuser@devuser.devuser"
|
||||
# PGADMIN_DEFAULT_PASSWORD: "changeme"
|
||||
# PGADMIN_CONFIG_SERVER_MODE: "False"
|
||||
# POSTGRES_USER: "devuser"
|
||||
# POSTGRES_PASSWORD: "changeme"
|
||||
# volumes:
|
||||
# - pgadmin-data:/var/lib/pgadmin
|
||||
# ports:
|
||||
# - "8080:80"
|
||||
# restart: unless-stopped
|
||||
# depends_on:
|
||||
# - db
|
||||
|
||||
s3:
|
||||
container_name: s3-portabase-dev
|
||||
image: docker.io/bitnami/minio:latest
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
|
||||
environment:
|
||||
- MINIO_ROOT_USER=${S3_ACCESS_KEY}
|
||||
- MINIO_ROOT_PASSWORD=${S3_SECRET_KEY}
|
||||
- MINIO_DEFAULT_BUCKETS=${S3_BUCKET_NAME}
|
||||
- MINIO_BROWSER=on
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
environment:
|
||||
- POSTGRES_DB=devdb
|
||||
- POSTGRES_USER=devuser
|
||||
- POSTGRES_PASSWORD=changeme
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -U devuser -d devdb" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
minio_data:
|
||||
# pgadmin-data:
|
||||
postgres-data:
|
||||
|
||||
@@ -1,44 +1,37 @@
|
||||
FROM node:22-alpine AS base
|
||||
|
||||
ENV YARN_VERSION=4.9.1
|
||||
RUN apk add --update --no-cache \
|
||||
libc6-compat \
|
||||
openssl \
|
||||
tzdata
|
||||
|
||||
FROM base AS build-env
|
||||
|
||||
RUN apk add --update --no-cache \
|
||||
build-base \
|
||||
libc6-compat \
|
||||
openssl \
|
||||
make \
|
||||
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
|
||||
|
||||
@@ -51,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
|
||||
@@ -60,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
|
||||
@@ -94,9 +89,6 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --chown=nextjs:nodejs src/db ./src/db
|
||||
|
||||
|
||||
|
||||
|
||||
USER root
|
||||
|
||||
COPY ./docker/entrypoints/app-prod-entrypoint.sh /app/app-prod-entrypoint.sh
|
||||
@@ -111,4 +103,3 @@ ENV HOSTNAME="0.0.0.0"
|
||||
USER nextjs
|
||||
|
||||
ENTRYPOINT ["sh","/app/app-prod-entrypoint.sh"]
|
||||
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
#npx drizzle-kit generate
|
||||
#npx drizzle-kit migrate
|
||||
#
|
||||
#npm run dev
|
||||
#
|
||||
#exec "$@"
|
||||
|
||||
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
|
||||
@@ -1,14 +1,14 @@
|
||||
#!/bin/bash
|
||||
|
||||
#echo " ____ __ __ "
|
||||
#echo " / __ \____ _____/ /_____ _/ /_ ____ _________ "
|
||||
#echo " / /_/ / __ \/ ___/ __/ __ / __ \/ __ / ___/ _ \ "
|
||||
#echo " / ____/ /_/ / / / /_/ /_/ / /_/ / /_/ (__ ) __/ "
|
||||
#echo " /_/ \____/_/ \__/\__,_/_.___/\__,_/____/\___/ "
|
||||
#echo " "
|
||||
#echo " Community Edition v1.1.1 "
|
||||
#echo " "
|
||||
if [ -n "$TZ" ]; then
|
||||
echo "[INFO] Application timezone set to $TZ (environment only)"
|
||||
export TZ="$TZ"
|
||||
else
|
||||
echo "[WARN] No TZ provided, using default container timezone"
|
||||
fi
|
||||
|
||||
|
||||
node server.js
|
||||
|
||||
exec "$@"
|
||||
exec "$@"
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { Button as ReactEmailButton } from "@react-email/components";
|
||||
import { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export const Button = (props: ComponentPropsWithoutRef<typeof ReactEmailButton>) => {
|
||||
return <ReactEmailButton className="block w-52 rounded bg-blue-600 py-3.5 text-center text-sm font-normal text-white no-underline " {...props} />;
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Body, Container, Head, Html, Img, Preview, Section, Tailwind } from "@react-email/components";
|
||||
import * as React from "react";
|
||||
import { PropsWithChildren } from "react";
|
||||
import { getServerUrl } from "@/utils/get-server-url";
|
||||
|
||||
const baseUrl = getServerUrl();
|
||||
|
||||
export const EmailLayout = ({ children, preview }: PropsWithChildren<{ preview?: string }>) => {
|
||||
return (
|
||||
<Tailwind>
|
||||
<Html>
|
||||
<Head />
|
||||
{preview ? <Preview>{preview}</Preview> : <Preview>Please check your mails</Preview>}
|
||||
<Body className="bg-gray-100 py-4" style={{ fontFamily: "Arial, sans-serif" }}>
|
||||
<Container className="bg-white border border-gray-200 p-12">
|
||||
<Img src={`${baseUrl}/logo-title-black.png`} width="200" height="auto" alt="Logo" />
|
||||
<Section>{children}</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
</Tailwind>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailLayout;
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Button } from "@react-email/button";
|
||||
import { Html } from "@react-email/html";
|
||||
import * as React from "react";
|
||||
|
||||
export default function HelloEmail() {
|
||||
return (
|
||||
<Html>
|
||||
<Button href="https://example.com" style={{ background: "#000", color: "#fff" }}>
|
||||
Hello,
|
||||
</Button>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Text } from "@react-email/components";
|
||||
import * as React from "react";
|
||||
import EmailLayout from "./EmailLayout";
|
||||
import { Button } from "@react-email/button";
|
||||
|
||||
export const TestEmailSettings = () => {
|
||||
return (
|
||||
<EmailLayout preview="Email Setup">
|
||||
<Text className="text-base font-light leading-8 text-green-800 ">Hi, your email settings are setup !</Text>
|
||||
<Text className="text-base font-light leading-8 text-green-800 ">Best regard,</Text>{" "}
|
||||
<Text className="text-base font-light leading-8 text-green-800 ">Portabase</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default TestEmailSettings;
|
||||
@@ -1,6 +0,0 @@
|
||||
import { Text as ReactEmailText } from "@react-email/components";
|
||||
import { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export const Text = (props: ComponentPropsWithoutRef<typeof ReactEmailText>) => {
|
||||
return <ReactEmailText className="text-base font-light leading-8 text-green-800 " {...props} />;
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import {defineConfig, globalIgnores} from 'eslint/config'
|
||||
import nextVitals from 'eslint-config-next/core-web-vitals'
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
globalIgnores([
|
||||
'.next/**',
|
||||
'out/**',
|
||||
'build/**',
|
||||
'next-env.d.ts',
|
||||
]),
|
||||
])
|
||||
|
||||
export default eslintConfig
|
||||
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -41,8 +41,12 @@ const nextConfig: NextConfig = {
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: "10gb",
|
||||
},
|
||||
proxyClientMaxBodySize: '10gb',
|
||||
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
|
||||
@@ -1,115 +1,124 @@
|
||||
{
|
||||
"name": "portabase",
|
||||
"version": "1.1.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 8887",
|
||||
"build": "next build --experimental-build-mode compile",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"email": "email dev",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:drop": "drizzle-kit drop",
|
||||
"auth:generate": "npx @better-auth/cli generate --config ./src/lib/auth/auth.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@radix-ui/react-accordion": "^1.2.10",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.13",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.6",
|
||||
"@radix-ui/react-avatar": "^1.1.9",
|
||||
"@radix-ui/react-checkbox": "^1.3.1",
|
||||
"@radix-ui/react-collapsible": "^1.1.10",
|
||||
"@radix-ui/react-context-menu": "^2.2.14",
|
||||
"@radix-ui/react-dialog": "^1.1.13",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.14",
|
||||
"@radix-ui/react-hover-card": "^1.1.13",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.6",
|
||||
"@radix-ui/react-menubar": "^1.1.14",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.12",
|
||||
"@radix-ui/react-popover": "^1.1.13",
|
||||
"@radix-ui/react-progress": "^1.1.6",
|
||||
"@radix-ui/react-radio-group": "^1.3.6",
|
||||
"@radix-ui/react-scroll-area": "^1.2.8",
|
||||
"@radix-ui/react-select": "^2.2.4",
|
||||
"@radix-ui/react-separator": "^1.1.6",
|
||||
"@radix-ui/react-slider": "^1.3.4",
|
||||
"@radix-ui/react-slot": "^1.2.2",
|
||||
"@radix-ui/react-switch": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.11",
|
||||
"@radix-ui/react-toast": "^1.2.13",
|
||||
"@radix-ui/react-toggle": "^1.1.8",
|
||||
"@radix-ui/react-toggle-group": "^1.1.9",
|
||||
"@radix-ui/react-tooltip": "^1.2.6",
|
||||
"@react-email/components": "^0.0.41",
|
||||
"@t3-oss/env-nextjs": "^0.13.4",
|
||||
"@tanstack/react-query": "^5.76.1",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@zenstackhq/runtime": "2.14.2",
|
||||
"argon2": "^0.43.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-auth": "1.3.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dockerode": "^4.0.6",
|
||||
"dotenv": "^16.5.0",
|
||||
"drizzle-orm": "^0.43.1",
|
||||
"drizzle-zod": "^0.7.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.510.0",
|
||||
"minio": "^8.0.5",
|
||||
"next": "15.5.2",
|
||||
"next-safe-action": "^7.10.8",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^7.0.3",
|
||||
"npm-check-updates": "^18.0.1",
|
||||
"pg": "^8.16.0",
|
||||
"react": "19.1.0",
|
||||
"react-day-picker": "9.7.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-email": "^4.0.13",
|
||||
"react-hook-form": "^7.56.3",
|
||||
"react-resizable-panels": "^3.0.2",
|
||||
"react-twc": "^1.4.2",
|
||||
"recharts": "^2.15.3",
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"sonner": "^2.0.3",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"uuid": "^11.1.0",
|
||||
"vaul": "^1.1.2",
|
||||
"ws": "^8.18.2",
|
||||
"zod": "^3.24.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/react": "^6.0.0",
|
||||
"@tailwindcss/postcss": "^4.1.7",
|
||||
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
||||
"@types/node": "^22.15.18",
|
||||
"@types/pg": "^8.15.2",
|
||||
"@types/react": "^19.1.4",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@zenstackhq/openapi": "^2.14.2",
|
||||
"@zenstackhq/tanstack-query": "^2.14.2",
|
||||
"drizzle-kit": "^0.31.1",
|
||||
"eslint": "^9.26.0",
|
||||
"eslint-config-next": "15.3.2",
|
||||
"eslint-plugin-tailwindcss": "^3.18.0",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"tsx": "^4.19.4",
|
||||
"tw-animate-css": "^1.2.9",
|
||||
"typescript": "^5.8.3",
|
||||
"zenstack": "2.14.2"
|
||||
},
|
||||
"packageManager": "yarn@4.9.1"
|
||||
"name": "portabase",
|
||||
"version": "1.1.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 8887",
|
||||
"build": "next build --experimental-build-mode compile",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"email": "email dev --dir ./src/components/emails",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:drop": "drizzle-kit drop",
|
||||
"auth:generate": "npx @better-auth/cli generate --config ./src/lib/auth/auth.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@radix-ui/react-accordion": "^1.2.10",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.13",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.6",
|
||||
"@radix-ui/react-avatar": "^1.1.9",
|
||||
"@radix-ui/react-checkbox": "^1.3.1",
|
||||
"@radix-ui/react-collapsible": "^1.1.10",
|
||||
"@radix-ui/react-context-menu": "^2.2.14",
|
||||
"@radix-ui/react-dialog": "^1.1.13",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.14",
|
||||
"@radix-ui/react-hover-card": "^1.1.13",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.6",
|
||||
"@radix-ui/react-menubar": "^1.1.14",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.12",
|
||||
"@radix-ui/react-popover": "^1.1.13",
|
||||
"@radix-ui/react-progress": "^1.1.6",
|
||||
"@radix-ui/react-radio-group": "^1.3.6",
|
||||
"@radix-ui/react-scroll-area": "^1.2.8",
|
||||
"@radix-ui/react-select": "^2.2.4",
|
||||
"@radix-ui/react-separator": "^1.1.6",
|
||||
"@radix-ui/react-slider": "^1.3.4",
|
||||
"@radix-ui/react-slot": "^1.2.2",
|
||||
"@radix-ui/react-switch": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.11",
|
||||
"@radix-ui/react-toast": "^1.2.13",
|
||||
"@radix-ui/react-toggle": "^1.1.8",
|
||||
"@radix-ui/react-toggle-group": "^1.1.9",
|
||||
"@radix-ui/react-tooltip": "^1.2.6",
|
||||
"@react-email/components": "^0.0.41",
|
||||
"@t3-oss/env-nextjs": "^0.13.4",
|
||||
"@tanstack/react-query": "^5.76.1",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@zenstackhq/runtime": "2.14.2",
|
||||
"argon2": "^0.43.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-auth": "1.4.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dockerode": "^4.0.6",
|
||||
"dotenv": "^16.5.0",
|
||||
"drizzle-orm": "^0.43.1",
|
||||
"drizzle-zod": "^0.7.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.553.0",
|
||||
"minio": "^8.0.5",
|
||||
"motion": "^12.23.24",
|
||||
"next": "16.0.10",
|
||||
"next-safe-action": "^7.10.8",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-forge": "^1.3.1",
|
||||
"nodemailer": "^7.0.3",
|
||||
"npm-check-updates": "^18.0.1",
|
||||
"pg": "^8.16.0",
|
||||
"react": "^19.2.0",
|
||||
"react-day-picker": "9.7.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-email": "^4.0.13",
|
||||
"react-hook-form": "^7.56.3",
|
||||
"react-qr-code": "^2.0.18",
|
||||
"react-resizable-panels": "^3.0.2",
|
||||
"react-twc": "^1.4.2",
|
||||
"react-use-measure": "^2.1.7",
|
||||
"recharts": "^2.15.3",
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"sonner": "^2.0.3",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"uuid": "^11.1.0",
|
||||
"vaul": "^1.1.2",
|
||||
"ws": "^8.18.2",
|
||||
"zod": "^3.24.4"
|
||||
},
|
||||
"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",
|
||||
"@types/node-forge": "^1",
|
||||
"@types/pg": "^8.15.2",
|
||||
"@types/react": "^19.1.4",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@zenstackhq/openapi": "^2.14.2",
|
||||
"@zenstackhq/tanstack-query": "^2.14.2",
|
||||
"baseline-browser-mapping": "^2.8.32",
|
||||
"drizzle-kit": "^0.31.1",
|
||||
"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",
|
||||
"tw-animate-css": "^1.2.9",
|
||||
"typescript": "^5.8.3",
|
||||
"zenstack": "2.14.2"
|
||||
},
|
||||
"packageManager": "pnpm@10.27.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
onlyBuiltDependencies:
|
||||
- zenstack
|
||||
@@ -1,4 +1,17 @@
|
||||
|
||||
|
||||
export interface AuthProviderConfig {
|
||||
id: "google" | "github" | "credential";
|
||||
isActive: boolean;
|
||||
icon: string;
|
||||
isManual?: boolean;
|
||||
credentials?: {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export const PORTABASE_DEFAULT_SETTINGS = {
|
||||
SECURITY: {
|
||||
CSP: {
|
||||
@@ -43,6 +56,8 @@ export const PORTABASE_DEFAULT_SETTINGS = {
|
||||
"'self'",
|
||||
"https://api.iconify.design",
|
||||
"https://code.iconify.design",
|
||||
"https://api.github.com",
|
||||
|
||||
],
|
||||
OBJECT_SRC: ["'none'"],
|
||||
BASE_URI: ["'self'"],
|
||||
@@ -60,3 +75,36 @@ export const PORTABASE_DEFAULT_SETTINGS = {
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
export const SUPPORTED_PROVIDERS: AuthProviderConfig[] = [
|
||||
{
|
||||
id: "google",
|
||||
icon: "hugeicons:chrome",
|
||||
isActive: Boolean(process.env.AUTH_GOOGLE_METHOD),
|
||||
// isManual: true,
|
||||
credentials: {
|
||||
clientId: process.env.AUTH_GOOGLE_ID || "",
|
||||
clientSecret: process.env.AUTH_GOOGLE_SECRET || "",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "github",
|
||||
icon: "iconoir:github",
|
||||
isActive: Boolean(process.env.AUTH_GITHUB_METHOD),
|
||||
credentials: {
|
||||
clientId: process.env.AUTH_GITHUB_ID || "",
|
||||
clientSecret: process.env.AUTH_GITHUB_SECRET || "",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "credential",
|
||||
isActive: true,
|
||||
icon: "proicons:key",
|
||||
isManual: true,
|
||||
credentials: {
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { loggingMiddleware } from "@/middleware/loggingMiddleware";
|
||||
import { errorHandler } from "@/middleware/errorHandler";
|
||||
import { auth } from "@/lib/auth/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { signOut } from "@/lib/auth/auth-client";
|
||||
import {NextRequest, NextResponse} from "next/server";
|
||||
import {loggingMiddleware} from "@/middleware/loggingMiddleware";
|
||||
import {errorHandler} from "@/middleware/errorHandler";
|
||||
import {auth} from "@/lib/auth/auth";
|
||||
import {headers} from "next/headers";
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
export async function proxy(request: NextRequest) {
|
||||
const url = request.nextUrl.clone();
|
||||
const redirectUrl = encodeURIComponent(request.nextUrl.pathname)
|
||||
|
||||
@@ -13,40 +12,33 @@ export async function middleware(request: NextRequest) {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.redirect(new URL(`/login?redirect=${redirectUrl}`, request.url));
|
||||
}
|
||||
|
||||
if (session.user.banned) {
|
||||
signOut();
|
||||
await auth.api.signOut({headers: await headers()});
|
||||
return NextResponse.redirect(new URL("/login?error=banned", request.url));
|
||||
}
|
||||
|
||||
if (session.user.role === "pending") {
|
||||
signOut();
|
||||
await auth.api.signOut({headers: await headers()});
|
||||
return NextResponse.redirect(new URL(`/login?error=pending?redirect=${redirectUrl}`, request.url));
|
||||
}
|
||||
|
||||
if (url.pathname === "/dashboard") {
|
||||
return NextResponse.redirect(new URL(`/dashboard/home`, request.url));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Exclude `/api/auth` and its subpaths
|
||||
if (url.pathname.startsWith("/api/auth")) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api")) {
|
||||
const routeExists = checkRouteExists(url.pathname);
|
||||
// If the route does not exist, return a 404 JSON response
|
||||
if (!routeExists) {
|
||||
return new NextResponse(JSON.stringify({ message: "This API route does not exist.", status: 404 }), {
|
||||
return new NextResponse(JSON.stringify({message: "This API route does not exist.", status: 404}), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: {"Content-Type": "application/json"},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -56,31 +48,25 @@ export async function middleware(request: NextRequest) {
|
||||
errorHandler(err);
|
||||
}
|
||||
}
|
||||
// Function to check if the route exists (supports dynamic routes)
|
||||
|
||||
function checkRouteExists(pathname: string) {
|
||||
// Define static and dynamic routes with patterns
|
||||
const routePatterns = [
|
||||
//do not delete
|
||||
// /^\/api\/auth\/\d+$/, // Dynamic route with a number as a parameter (e.g., /api/dynamic/123)
|
||||
// /^\/api\/auth\/\w+$/, // Dynamic route with a number as a parameter (e.g., /api/dynamic/123)
|
||||
// /^\/api\/agent\/healthcheck\/\w+$/, // Dynamic route with an alphanumeric parameter (e.g., /api/user/username)
|
||||
/^\/api\/agent\/[^/]+\/status\/?$/, // Dynamic route for /api/agent/[id]/status
|
||||
/^\/api\/agent\/[^/]+\/status\/?$/,
|
||||
/^\/api\/agent\/[^/]+\/backup\/?$/,
|
||||
/^\/api\/agent\/[^/]+\/restore\/?$/,
|
||||
/^\/api\/files\/[^/]+\/?$/,
|
||||
/^\/api\/images\/[^/]+\/?$/,
|
||||
/^\/api\/events\/?$/,
|
||||
/^\/api\/init\/?$/,
|
||||
/^\/api\/config\/?$/,
|
||||
];
|
||||
return routePatterns.some((pattern) => pattern.test(pathname));
|
||||
}
|
||||
|
||||
export const config = {
|
||||
runtime: "nodejs",
|
||||
matcher: [
|
||||
// '/api/agent/:path*',
|
||||
"/api/:path*",
|
||||
"/dashboard/:path*",
|
||||
"/dashboard",
|
||||
],
|
||||
]
|
||||
};
|
||||
|
Before Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,38 @@
|
||||
import * as React from "react";
|
||||
import EmailLayout from "../email-layout";
|
||||
import {Heading, Text, Section, Button} from "@react-email/components";
|
||||
import {getServerUrl} from "@/utils/get-server-url";
|
||||
|
||||
interface EmailCreateUserProps {
|
||||
firstname?: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export const EmailForgotPassword = ({firstname, token}: EmailCreateUserProps) => {
|
||||
const baseUrl = getServerUrl();
|
||||
|
||||
return (
|
||||
<EmailLayout preview="Portabase - Forgot Password">
|
||||
<Heading className="mx-0 my-[30px] p-0 text-center font-normal text-[24px] text-black">
|
||||
Hello <strong>{firstname}</strong>,
|
||||
</Heading>
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
We received a request to reset the password for your account associated with this email.
|
||||
</Text>
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
If you did not request this password reset, please ignore this email. Your current password
|
||||
will remain unchanged. If this seems suspicious, please contact your administrator.
|
||||
</Text>
|
||||
<Section className="mt-[32px] mb-[32px] text-center">
|
||||
<Button
|
||||
className="rounded bg-[#000000] px-5 py-3 text-center font-semibold text-[12px] text-white no-underline"
|
||||
href={`${baseUrl}/reset-password?token=${token}`}
|
||||
>
|
||||
Reset my password
|
||||
</Button>
|
||||
</Section>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailForgotPassword;
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as React from "react";
|
||||
import EmailLayout from "../email-layout";
|
||||
import {Heading, Text} from "@react-email/components";
|
||||
|
||||
interface EmailCreateUserProps {
|
||||
firstname?: string;
|
||||
os: string;
|
||||
browser: string;
|
||||
ipAddress?: string;
|
||||
}
|
||||
|
||||
export const EmailNewLogin = ({firstname, ipAddress, os, browser}: EmailCreateUserProps) => {
|
||||
return (
|
||||
<EmailLayout preview="Portabase - New Login">
|
||||
<Heading className="mx-0 my-[30px] p-0 text-center font-normal text-[24px] text-black">
|
||||
Hello <strong>{firstname}</strong>,
|
||||
</Heading>
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
We detected a new login to your account.
|
||||
</Text>
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
<strong>Login details:</strong>
|
||||
<br/>
|
||||
Device: {os} - {browser}
|
||||
<br/>
|
||||
IP Address: {ipAddress}
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailNewLogin;
|
||||
@@ -0,0 +1,51 @@
|
||||
import {Heading, Text, Section, Button} from "@react-email/components";
|
||||
import {getServerUrl} from "@/utils/get-server-url";
|
||||
import EmailLayout from "@/components/emails/email-layout";
|
||||
|
||||
interface EmailCreateUserProps {
|
||||
firstname?: string;
|
||||
oldEmail?: string;
|
||||
newEmail?: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const EmailVerification = ({firstname, oldEmail, newEmail, url: urlVerification}: EmailCreateUserProps) => {
|
||||
const serverUrl = new URL(getServerUrl());
|
||||
const url = new URL(urlVerification);
|
||||
url.hostname = serverUrl.hostname;
|
||||
url.port = serverUrl.port == "80" ? "" : serverUrl.port;
|
||||
const newUrl = url.toString();
|
||||
|
||||
return (
|
||||
<EmailLayout preview="Portabase email verification">
|
||||
<Heading className="mx-0 my-[30px] p-0 text-center font-normal text-[24px] text-black">
|
||||
Hello <strong>{firstname}</strong>,
|
||||
</Heading>
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
We received a request to change the email address associated with your account.
|
||||
</Text>
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
If you did not request this change, please ignore this email. Your current email address will remain
|
||||
unchanged. If this seems suspicious, contact your administrator.
|
||||
</Text>
|
||||
|
||||
{oldEmail && newEmail ? (
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
<strong>Old email address:</strong> {oldEmail}
|
||||
<br/>
|
||||
<strong>New email address:</strong> {newEmail}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Section className="mt-[32px] mb-[32px] text-center">
|
||||
<Button
|
||||
className="rounded bg-[#000000] px-5 py-3 text-center font-semibold text-[12px] text-white no-underline"
|
||||
href={newUrl}>
|
||||
Confirm email address
|
||||
</Button>
|
||||
</Section>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailVerification;
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as React from "react";
|
||||
import EmailLayout from "./email-layout";
|
||||
import {Heading, Text, Section, Button} from "@react-email/components";
|
||||
import {getServerUrl} from "@/utils/get-server-url";
|
||||
import {env} from "@/env.mjs";
|
||||
|
||||
interface EmailCreateUserProps {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export const EmailCreateUser = ({email, password}: EmailCreateUserProps) => {
|
||||
const baseUrl = getServerUrl();
|
||||
|
||||
|
||||
return (
|
||||
<EmailLayout preview="Portabase Dashboard">
|
||||
<Heading className="mx-0 my-[30px] p-0 text-center font-normal text-[24px] text-black">
|
||||
Your account on {env.PROJECT_NAME} has just been created!
|
||||
</Heading>
|
||||
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
<strong>Email: </strong>{email}
|
||||
</Text>
|
||||
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
<strong>Default password: </strong>{password}
|
||||
</Text>
|
||||
|
||||
<Section className="mt-[32px] mb-[32px] text-center">
|
||||
<Button
|
||||
className="rounded bg-[#000000] px-5 py-3 text-center font-semibold text-[12px] text-white no-underline"
|
||||
href={baseUrl}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
</Section>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailCreateUser;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Body, Container, Head, Html, Img, Preview, Section, Tailwind } from "@react-email/components";
|
||||
import * as React from "react";
|
||||
import { PropsWithChildren } from "react";
|
||||
import { getServerUrl } from "@/utils/get-server-url";
|
||||
|
||||
const baseUrl = getServerUrl();
|
||||
|
||||
export const EmailLayout = ({ children, preview }: PropsWithChildren<{ preview?: string }>) => {
|
||||
return (
|
||||
<Tailwind>
|
||||
<Html>
|
||||
<Head />
|
||||
<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>
|
||||
</Tailwind>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailLayout;
|
||||
@@ -0,0 +1,97 @@
|
||||
// import * as React from "react";
|
||||
// import EmailLayout from "./email-layout";
|
||||
// import {Text, Section, Button} from "@react-email/components";
|
||||
// import type {EventPayload} from "@/features/notifications/types";
|
||||
//
|
||||
// export interface EmailNotificationProps {
|
||||
// payload: EventPayload
|
||||
// }
|
||||
//
|
||||
// export const EmailNotification = ({payload}: EmailNotificationProps) => {
|
||||
// return (
|
||||
// <EmailLayout preview={payload.title}>
|
||||
// <Text className="text-base text-green-800 font-bold ">${payload.title}</Text>
|
||||
// <Text className="text-base text-green-800 font-bold "><strong>Level:</strong> ${payload.level}</Text>
|
||||
//
|
||||
//
|
||||
// <Text className="text-base font-light text-green-800 ">
|
||||
// ${payload.message.replace(/\n/g, '<br>')}
|
||||
// </Text>{" "}
|
||||
//
|
||||
//
|
||||
// <Section className="mt-[32px] mb-[32px] text-center">
|
||||
//
|
||||
// </Section>
|
||||
//
|
||||
// {payload.data && (
|
||||
// <Section className="mt-[16px] text-sm text-gray-700">
|
||||
// <pre>{JSON.stringify(payload.data, null, 2)}</pre>
|
||||
// </Section>
|
||||
// )}
|
||||
//
|
||||
// <Text className="text-base font-light text-green-800 ">Regards,<br/>Portabase</Text>
|
||||
// </EmailLayout>
|
||||
// );
|
||||
// };
|
||||
//
|
||||
// export default EmailNotification;
|
||||
|
||||
|
||||
// const html = `
|
||||
// <h2>${payload.title}</h2>
|
||||
// <p><strong>Level:</strong> ${payload.level}</p>
|
||||
// <p>${payload.message.replace(/\n/g, '<br>')}</p>
|
||||
// ${payload.data ? `<pre>${JSON.stringify(payload.data, null, 2)}</pre>` : ''}
|
||||
// `;
|
||||
//
|
||||
|
||||
import * as React from "react";
|
||||
import EmailLayout from "./email-layout";
|
||||
import {Text, Section, Button} from "@react-email/components";
|
||||
import type {EventPayload} from "@/features/notifications/types";
|
||||
|
||||
export interface EmailNotificationProps {
|
||||
payload: EventPayload;
|
||||
}
|
||||
|
||||
export const EmailNotification = ({payload}: EmailNotificationProps) => {
|
||||
return (
|
||||
<EmailLayout preview={payload.message}>
|
||||
<Section>
|
||||
<Text className="text-xl font-bold ">
|
||||
{payload.title}
|
||||
</Text>
|
||||
<Text className=" font-semibold ">
|
||||
Level: {payload.level}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{payload.message && (
|
||||
<Section className="mb-2">
|
||||
<Text
|
||||
className="text-base font-light"
|
||||
dangerouslySetInnerHTML={{__html: payload.message.replace(/\n/g, "<br>")}}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{payload.data && (
|
||||
<Section className="mb-6 p-4 bg-gray-100 rounded text-sm text-gray-700">
|
||||
<Text>
|
||||
<pre>{JSON.stringify(payload.data, null, 2)}</pre>
|
||||
</Text>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section className="mt-8">
|
||||
<Text className="text-base font-light ">
|
||||
Regards,
|
||||
<br/>
|
||||
Portabase
|
||||
</Text>
|
||||
</Section>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailNotification;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Text } from "@react-email/components";
|
||||
import * as React from "react";
|
||||
import EmailLayout from "./email-layout";
|
||||
|
||||
export const EmailSettingsTest = () => {
|
||||
return (
|
||||
<EmailLayout preview="Email Setup">
|
||||
<Text className="text-base font-light leading-8 ">Hi, your email settings are setup !</Text>
|
||||
<Text className="text-base font-light leading-8 ">Best regard,</Text>{" "}
|
||||
<Text className="text-base font-light leading-8 ">Portabase</Text>
|
||||
</EmailLayout>
|
||||
)
|
||||
};
|
||||
|
||||
export default EmailSettingsTest;
|
||||
@@ -0,0 +1,6 @@
|
||||
import {Text as ReactEmailText} from "@react-email/components";
|
||||
import {ComponentPropsWithoutRef} from "react";
|
||||
|
||||
export const EmailText = (props: ComponentPropsWithoutRef<typeof ReactEmailText>) => {
|
||||
return <ReactEmailText className="text-base font-light leading-8 text-green-800 " {...props} />;
|
||||
};
|
||||
@@ -7,117 +7,118 @@ import { XIcon } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
"max-h-[90vh] overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,850 @@
|
||||
"use client"
|
||||
import {cn} from "@/lib/utils";
|
||||
import {
|
||||
createContext,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useContext,
|
||||
useId,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Accept,
|
||||
FileRejection,
|
||||
useDropzone as rootUseDropzone,
|
||||
} from "react-dropzone";
|
||||
import {Button, ButtonVariantsProps} from "@/components/ui/button";
|
||||
|
||||
type DropzoneResult<TUploadRes, TUploadError> =
|
||||
| {
|
||||
status: "pending";
|
||||
}
|
||||
| {
|
||||
status: "error";
|
||||
error: TUploadError;
|
||||
}
|
||||
| {
|
||||
status: "success";
|
||||
result: TUploadRes;
|
||||
};
|
||||
|
||||
export type FileStatus<TUploadRes, TUploadError> = {
|
||||
id: string;
|
||||
fileName: string;
|
||||
file: File;
|
||||
tries: number;
|
||||
} & (
|
||||
| {
|
||||
status: "pending";
|
||||
result?: undefined;
|
||||
error?: undefined;
|
||||
}
|
||||
| {
|
||||
status: "error";
|
||||
error: TUploadError;
|
||||
result?: undefined;
|
||||
}
|
||||
| {
|
||||
status: "success";
|
||||
result: TUploadRes;
|
||||
error?: undefined;
|
||||
}
|
||||
);
|
||||
|
||||
const fileStatusReducer = <TUploadRes, TUploadError>(
|
||||
state: FileStatus<TUploadRes, TUploadError>[],
|
||||
action:
|
||||
| {
|
||||
type: "add";
|
||||
id: string;
|
||||
fileName: string;
|
||||
file: File;
|
||||
}
|
||||
| {
|
||||
type: "remove";
|
||||
id: string;
|
||||
}
|
||||
| ({
|
||||
type: "update-status";
|
||||
id: string;
|
||||
} & DropzoneResult<TUploadRes, TUploadError>),
|
||||
): FileStatus<TUploadRes, TUploadError>[] => {
|
||||
switch (action.type) {
|
||||
case "add":
|
||||
return [
|
||||
...state,
|
||||
{
|
||||
id: action.id,
|
||||
fileName: action.fileName,
|
||||
file: action.file,
|
||||
status: "pending",
|
||||
tries: 1,
|
||||
},
|
||||
];
|
||||
case "remove":
|
||||
return state.filter((fileStatus) => fileStatus.id !== action.id);
|
||||
case "update-status":
|
||||
return state.map((fileStatus) => {
|
||||
if (fileStatus.id === action.id) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const {id, type, ...rest} = action;
|
||||
return {
|
||||
...fileStatus,
|
||||
...rest,
|
||||
tries:
|
||||
action.status === "pending"
|
||||
? fileStatus.tries + 1
|
||||
: fileStatus.tries,
|
||||
} as FileStatus<TUploadRes, TUploadError>;
|
||||
}
|
||||
return fileStatus;
|
||||
});
|
||||
}
|
||||
};
|
||||
type DropZoneErrorCode = (typeof dropZoneErrorCodes)[number];
|
||||
const dropZoneErrorCodes = [
|
||||
"file-invalid-type",
|
||||
"file-too-large",
|
||||
"file-too-small",
|
||||
"too-many-files",
|
||||
] as const;
|
||||
|
||||
const getDropZoneErrorCodes = (fileRejections: FileRejection[]) => {
|
||||
const errors = fileRejections.map((rejection) => {
|
||||
return rejection.errors
|
||||
.filter((error) =>
|
||||
dropZoneErrorCodes.includes(error.code as DropZoneErrorCode),
|
||||
)
|
||||
.map((error) => error.code) as DropZoneErrorCode[];
|
||||
});
|
||||
return Array.from(new Set(errors.flat()));
|
||||
};
|
||||
|
||||
const getRootError = (
|
||||
errorCodes: DropZoneErrorCode[],
|
||||
limits: {
|
||||
accept?: Accept;
|
||||
maxSize?: number;
|
||||
minSize?: number;
|
||||
maxFiles?: number;
|
||||
},
|
||||
) => {
|
||||
const errors = errorCodes.map((error) => {
|
||||
switch (error) {
|
||||
case "file-invalid-type":
|
||||
const acceptedTypes = Object.values(limits.accept ?? {})
|
||||
.flat()
|
||||
.join(", ");
|
||||
return `only ${acceptedTypes} are allowed`;
|
||||
case "file-too-large":
|
||||
const maxMb = limits.maxSize
|
||||
? (limits.maxSize / (1024 * 1024)).toFixed(2)
|
||||
: "infinite?";
|
||||
return `max size is ${maxMb}MB`;
|
||||
case "file-too-small":
|
||||
const roundedMinSize = limits.minSize
|
||||
? (limits.minSize / (1024 * 1024)).toFixed(2)
|
||||
: "negative?";
|
||||
return `min size is ${roundedMinSize}MB`;
|
||||
case "too-many-files":
|
||||
return `max ${limits.maxFiles} files`;
|
||||
}
|
||||
});
|
||||
const joinedErrors = errors.join(", ");
|
||||
return joinedErrors.charAt(0).toUpperCase() + joinedErrors.slice(1);
|
||||
};
|
||||
|
||||
type UseDropzoneProps<TUploadRes, TUploadError> = {
|
||||
onDropFile: (
|
||||
file: File,
|
||||
) => Promise<
|
||||
Exclude<DropzoneResult<TUploadRes, TUploadError>, { status: "pending" }>
|
||||
>;
|
||||
onRemoveFile?: (id: string) => void | Promise<void>;
|
||||
onFileUploaded?: (result: TUploadRes) => void;
|
||||
onFileUploadError?: (error: TUploadError) => void;
|
||||
onAllUploaded?: () => void;
|
||||
onRootError?: (error: string | undefined) => void;
|
||||
maxRetryCount?: number;
|
||||
autoRetry?: boolean;
|
||||
validation?: {
|
||||
accept?: Accept;
|
||||
minSize?: number;
|
||||
maxSize?: number;
|
||||
maxFiles?: number;
|
||||
};
|
||||
shiftOnMaxFiles?: boolean;
|
||||
} & (TUploadError extends string
|
||||
? {
|
||||
shapeUploadError?: (error: TUploadError) => string | void;
|
||||
}
|
||||
: {
|
||||
shapeUploadError: (error: TUploadError) => string | void;
|
||||
});
|
||||
|
||||
interface UseDropzoneReturn<TUploadRes, TUploadError> {
|
||||
getRootProps: ReturnType<typeof rootUseDropzone>["getRootProps"];
|
||||
getInputProps: ReturnType<typeof rootUseDropzone>["getInputProps"];
|
||||
onRemoveFile: (id: string) => Promise<void>;
|
||||
onRetry: (id: string) => Promise<void>;
|
||||
canRetry: (id: string) => boolean;
|
||||
fileStatuses: FileStatus<TUploadRes, TUploadError>[];
|
||||
isInvalid: boolean;
|
||||
isDragActive: boolean;
|
||||
rootError: string | undefined;
|
||||
inputId: string;
|
||||
rootMessageId: string;
|
||||
rootDescriptionId: string;
|
||||
getFileMessageId: (id: string) => string;
|
||||
}
|
||||
|
||||
const useDropzone = <TUploadRes, TUploadError = string>(
|
||||
props: UseDropzoneProps<TUploadRes, TUploadError>,
|
||||
): UseDropzoneReturn<TUploadRes, TUploadError> => {
|
||||
const {
|
||||
onDropFile: pOnDropFile,
|
||||
onRemoveFile: pOnRemoveFile,
|
||||
shapeUploadError: pShapeUploadError,
|
||||
onFileUploaded: pOnFileUploaded,
|
||||
onFileUploadError: pOnFileUploadError,
|
||||
onAllUploaded: pOnAllUploaded,
|
||||
onRootError: pOnRootError,
|
||||
maxRetryCount,
|
||||
autoRetry,
|
||||
validation,
|
||||
shiftOnMaxFiles,
|
||||
} = props;
|
||||
|
||||
const inputId = useId();
|
||||
const rootMessageId = `${inputId}-root-message`;
|
||||
const rootDescriptionId = `${inputId}-description`;
|
||||
const [rootError, _setRootError] = useState<string | undefined>(undefined);
|
||||
|
||||
const setRootError = useCallback(
|
||||
(error: string | undefined) => {
|
||||
_setRootError(error);
|
||||
if (pOnRootError !== undefined) {
|
||||
pOnRootError(error);
|
||||
}
|
||||
},
|
||||
[pOnRootError, _setRootError],
|
||||
);
|
||||
|
||||
const [fileStatuses, dispatch] = useReducer(fileStatusReducer, []);
|
||||
|
||||
const isInvalid = useMemo(() => {
|
||||
return (
|
||||
fileStatuses.filter((file) => file.status === "error").length > 0 ||
|
||||
rootError !== undefined
|
||||
);
|
||||
}, [fileStatuses, rootError]);
|
||||
|
||||
const _uploadFile = useCallback(
|
||||
async (file: File, id: string, tries = 0) => {
|
||||
const result = await pOnDropFile(file);
|
||||
|
||||
if (result.status === "error") {
|
||||
if (autoRetry === true && tries < (maxRetryCount ?? Infinity)) {
|
||||
dispatch({type: "update-status", id, status: "pending"});
|
||||
return _uploadFile(file, id, tries + 1);
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: "update-status",
|
||||
id,
|
||||
status: "error",
|
||||
error:
|
||||
pShapeUploadError !== undefined
|
||||
? pShapeUploadError(result.error)
|
||||
: result.error,
|
||||
});
|
||||
if (pOnFileUploadError !== undefined) {
|
||||
pOnFileUploadError(result.error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (pOnFileUploaded !== undefined) {
|
||||
pOnFileUploaded(result.result);
|
||||
}
|
||||
dispatch({
|
||||
type: "update-status",
|
||||
id,
|
||||
...result,
|
||||
});
|
||||
},
|
||||
[
|
||||
autoRetry,
|
||||
maxRetryCount,
|
||||
pOnDropFile,
|
||||
pShapeUploadError,
|
||||
pOnFileUploadError,
|
||||
pOnFileUploaded,
|
||||
],
|
||||
);
|
||||
|
||||
const onRemoveFile = useCallback(
|
||||
async (id: string) => {
|
||||
await pOnRemoveFile?.(id);
|
||||
dispatch({type: "remove", id});
|
||||
},
|
||||
[pOnRemoveFile],
|
||||
);
|
||||
|
||||
const canRetry = useCallback(
|
||||
(id: string) => {
|
||||
const fileStatus = fileStatuses.find((file) => file.id === id);
|
||||
return (
|
||||
fileStatus?.status === "error" &&
|
||||
fileStatus.tries < (maxRetryCount ?? Infinity)
|
||||
);
|
||||
},
|
||||
[fileStatuses, maxRetryCount],
|
||||
);
|
||||
|
||||
const onRetry = useCallback(
|
||||
async (id: string) => {
|
||||
if (!canRetry(id)) {
|
||||
return;
|
||||
}
|
||||
dispatch({type: "update-status", id, status: "pending"});
|
||||
const fileStatus = fileStatuses.find((file) => file.id === id);
|
||||
if (!fileStatus || fileStatus.status !== "error") {
|
||||
return;
|
||||
}
|
||||
await _uploadFile(fileStatus.file, id);
|
||||
},
|
||||
[canRetry, fileStatuses, _uploadFile],
|
||||
);
|
||||
|
||||
const getFileMessageId = (id: string) => `${inputId}-${id}-message`;
|
||||
|
||||
const dropzone = rootUseDropzone({
|
||||
accept: validation?.accept,
|
||||
minSize: validation?.minSize,
|
||||
maxSize: validation?.maxSize,
|
||||
onDropAccepted: async (newFiles) => {
|
||||
setRootError(undefined);
|
||||
|
||||
// useDropzone hook only checks max file count per group of uploaded files, allows going over if in multiple batches
|
||||
const fileCount = fileStatuses.length;
|
||||
const maxNewFiles =
|
||||
validation?.maxFiles === undefined
|
||||
? Infinity
|
||||
: validation?.maxFiles - fileCount;
|
||||
|
||||
if (maxNewFiles < newFiles.length) {
|
||||
if (shiftOnMaxFiles === true) {
|
||||
} else {
|
||||
setRootError(getRootError(["too-many-files"], validation ?? {}));
|
||||
}
|
||||
}
|
||||
|
||||
const slicedNewFiles =
|
||||
shiftOnMaxFiles === true ? newFiles : newFiles.slice(0, maxNewFiles);
|
||||
|
||||
const onDropFilePromises = slicedNewFiles.map(async (file, index) => {
|
||||
if (fileCount + 1 > maxNewFiles) {
|
||||
await onRemoveFile(fileStatuses[index].id);
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
dispatch({type: "add", fileName: file.name, file, id});
|
||||
await _uploadFile(file, id);
|
||||
});
|
||||
|
||||
await Promise.all(onDropFilePromises);
|
||||
if (pOnAllUploaded !== undefined) {
|
||||
pOnAllUploaded();
|
||||
}
|
||||
},
|
||||
onDropRejected: (fileRejections) => {
|
||||
const errorMessage = getRootError(
|
||||
getDropZoneErrorCodes(fileRejections),
|
||||
validation ?? {}
|
||||
);
|
||||
setRootError(errorMessage);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
getRootProps: dropzone.getRootProps,
|
||||
getInputProps: dropzone.getInputProps,
|
||||
inputId,
|
||||
rootMessageId,
|
||||
rootDescriptionId,
|
||||
getFileMessageId,
|
||||
onRemoveFile,
|
||||
onRetry,
|
||||
canRetry,
|
||||
fileStatuses: fileStatuses as FileStatus<TUploadRes, TUploadError>[],
|
||||
isInvalid,
|
||||
rootError,
|
||||
isDragActive: dropzone.isDragActive,
|
||||
};
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const DropZoneContext = createContext<UseDropzoneReturn<any, any>>({
|
||||
getRootProps: () => ({}) as never,
|
||||
getInputProps: () => ({}) as never,
|
||||
onRemoveFile: async () => {
|
||||
},
|
||||
onRetry: async () => {
|
||||
},
|
||||
canRetry: () => false,
|
||||
fileStatuses: [],
|
||||
isInvalid: false,
|
||||
isDragActive: false,
|
||||
rootError: undefined,
|
||||
inputId: "",
|
||||
rootMessageId: "",
|
||||
rootDescriptionId: "",
|
||||
getFileMessageId: () => "",
|
||||
});
|
||||
|
||||
const useDropzoneContext = <TUploadRes, TUploadError>() => {
|
||||
return useContext(DropZoneContext) as UseDropzoneReturn<
|
||||
TUploadRes,
|
||||
TUploadError
|
||||
>;
|
||||
};
|
||||
|
||||
interface DropzoneProps<TUploadRes, TUploadError>
|
||||
extends UseDropzoneReturn<TUploadRes, TUploadError> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Dropzone = <TUploadRes, TUploadError>(
|
||||
props: DropzoneProps<TUploadRes, TUploadError>,
|
||||
) => {
|
||||
const {children, ...rest} = props;
|
||||
return (
|
||||
<DropZoneContext.Provider value={rest}>{children}</DropZoneContext.Provider>
|
||||
);
|
||||
};
|
||||
Dropzone.displayName = "Dropzone";
|
||||
|
||||
interface DropZoneAreaProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
}
|
||||
|
||||
const DropZoneArea = forwardRef<HTMLDivElement, DropZoneAreaProps>(
|
||||
({className, children, ...props}, forwardedRef) => {
|
||||
const context = useDropzoneContext();
|
||||
|
||||
if (!context) {
|
||||
throw new Error("DropzoneArea must be used within a Dropzone");
|
||||
}
|
||||
|
||||
const {onFocus, onBlur, onDragEnter, onDragLeave, onDrop, ref} =
|
||||
context.getRootProps();
|
||||
|
||||
return (
|
||||
// A11y behavior is handled through Trigger. All of these are only relevant to drag and drop which means this should be fine?
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
ref={(instance) => {
|
||||
// TODO: test if this actually works?
|
||||
ref.current = instance;
|
||||
if (typeof forwardedRef === "function") {
|
||||
forwardedRef(instance);
|
||||
} else if (forwardedRef) {
|
||||
forwardedRef.current = instance;
|
||||
}
|
||||
}}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onDragEnter={onDragEnter}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
{...props}
|
||||
aria-label="dropzone"
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
context.isDragActive && "animate-pulse bg-black/5",
|
||||
context.isInvalid && "border-destructive",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
DropZoneArea.displayName = "DropZoneArea";
|
||||
|
||||
export interface DropzoneDescriptionProps
|
||||
extends React.HTMLAttributes<HTMLParagraphElement> {
|
||||
}
|
||||
|
||||
const DropzoneDescription = forwardRef<
|
||||
HTMLParagraphElement,
|
||||
DropzoneDescriptionProps
|
||||
>((props, ref) => {
|
||||
const {className, ...rest} = props;
|
||||
const context = useDropzoneContext();
|
||||
if (!context) {
|
||||
throw new Error("DropzoneDescription must be used within a Dropzone");
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={context.rootDescriptionId}
|
||||
{...rest}
|
||||
className={cn("pb-1 text-sm text-muted-foreground", className)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
DropzoneDescription.displayName = "DropzoneDescription";
|
||||
|
||||
interface DropzoneFileListContext<TUploadRes, TUploadError> {
|
||||
onRemoveFile: () => Promise<void>;
|
||||
onRetry: () => Promise<void>;
|
||||
fileStatus: FileStatus<TUploadRes, TUploadError>;
|
||||
canRetry: boolean;
|
||||
dropzoneId: string;
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
const DropzoneFileListContext = createContext<
|
||||
DropzoneFileListContext<unknown, unknown>
|
||||
>({
|
||||
onRemoveFile: async () => {
|
||||
},
|
||||
onRetry: async () => {
|
||||
},
|
||||
fileStatus: {} as FileStatus<unknown, unknown>,
|
||||
canRetry: false,
|
||||
dropzoneId: "",
|
||||
messageId: "",
|
||||
});
|
||||
|
||||
const useDropzoneFileListContext = () => {
|
||||
return useContext(DropzoneFileListContext);
|
||||
};
|
||||
|
||||
interface DropZoneFileListProps
|
||||
extends React.OlHTMLAttributes<HTMLOListElement> {
|
||||
}
|
||||
|
||||
const DropzoneFileList = forwardRef<HTMLOListElement, DropZoneFileListProps>(
|
||||
(props, ref) => {
|
||||
const context = useDropzoneContext();
|
||||
if (!context) {
|
||||
throw new Error("DropzoneFileList must be used within a Dropzone");
|
||||
}
|
||||
return (
|
||||
<ol
|
||||
ref={ref}
|
||||
aria-label="dropzone-file-list"
|
||||
{...props}
|
||||
className={cn("flex flex-col gap-4", props.className)}
|
||||
>
|
||||
{props.children}
|
||||
</ol>
|
||||
);
|
||||
},
|
||||
);
|
||||
DropzoneFileList.displayName = "DropzoneFileList";
|
||||
|
||||
interface DropzoneFileListItemProps<TUploadRes, TUploadError>
|
||||
extends React.LiHTMLAttributes<HTMLLIElement> {
|
||||
file: FileStatus<TUploadRes, TUploadError>;
|
||||
}
|
||||
|
||||
const DropzoneFileListItem = forwardRef<
|
||||
HTMLLIElement,
|
||||
DropzoneFileListItemProps<unknown, unknown>
|
||||
>(({className, ...props}, ref) => {
|
||||
const fileId = props.file.id;
|
||||
const {
|
||||
onRemoveFile: cOnRemoveFile,
|
||||
onRetry: cOnRetry,
|
||||
getFileMessageId: cGetFileMessageId,
|
||||
canRetry: cCanRetry,
|
||||
inputId: cInputId,
|
||||
} = useDropzoneContext();
|
||||
|
||||
const onRemoveFile = useCallback(
|
||||
() => cOnRemoveFile(fileId),
|
||||
[fileId, cOnRemoveFile],
|
||||
);
|
||||
const onRetry = useCallback(() => cOnRetry(fileId), [fileId, cOnRetry]);
|
||||
const messageId = cGetFileMessageId(fileId);
|
||||
const isInvalid = props.file.status === "error";
|
||||
const canRetry = useMemo(() => cCanRetry(fileId), [fileId, cCanRetry]);
|
||||
return (
|
||||
<DropzoneFileListContext.Provider
|
||||
value={{
|
||||
onRemoveFile,
|
||||
onRetry,
|
||||
fileStatus: props.file,
|
||||
canRetry,
|
||||
dropzoneId: cInputId,
|
||||
messageId,
|
||||
}}
|
||||
>
|
||||
<li
|
||||
ref={ref}
|
||||
aria-label="dropzone-file-list-item"
|
||||
aria-describedby={isInvalid ? messageId : undefined}
|
||||
className={cn(
|
||||
"flex flex-col justify-center gap-2 rounded-md bg-muted/40 px-4 py-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</li>
|
||||
</DropzoneFileListContext.Provider>
|
||||
);
|
||||
});
|
||||
DropzoneFileListItem.displayName = "DropzoneFileListItem";
|
||||
|
||||
interface DropzoneFileMessageProps
|
||||
extends React.HTMLAttributes<HTMLParagraphElement> {
|
||||
}
|
||||
|
||||
const DropzoneFileMessage = forwardRef<
|
||||
HTMLParagraphElement,
|
||||
DropzoneFileMessageProps
|
||||
>((props, ref) => {
|
||||
const {children, ...rest} = props;
|
||||
const context = useDropzoneFileListContext();
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"DropzoneFileMessage must be used within a DropzoneFileListItem",
|
||||
);
|
||||
}
|
||||
|
||||
const body =
|
||||
context.fileStatus.status === "error"
|
||||
? String(context.fileStatus.error)
|
||||
: children;
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={context.messageId}
|
||||
{...rest}
|
||||
className={cn(
|
||||
"h-5 text-[0.8rem] font-medium text-destructive",
|
||||
rest.className,
|
||||
)}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
});
|
||||
DropzoneFileMessage.displayName = "DropzoneFileMessage";
|
||||
|
||||
interface DropzoneMessageProps
|
||||
extends React.HTMLAttributes<HTMLParagraphElement> {
|
||||
}
|
||||
|
||||
const DropzoneMessage = forwardRef<HTMLParagraphElement, DropzoneMessageProps>(
|
||||
(props, ref) => {
|
||||
const {children, ...rest} = props;
|
||||
const context = useDropzoneContext();
|
||||
if (!context) {
|
||||
throw new Error("DropzoneRootMessage must be used within a Dropzone");
|
||||
}
|
||||
|
||||
const body = context.rootError ? String(context.rootError) : children;
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={context.rootMessageId}
|
||||
{...rest}
|
||||
className={cn(
|
||||
"h-5 text-[0.8rem] font-medium text-destructive",
|
||||
rest.className,
|
||||
)}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
},
|
||||
);
|
||||
DropzoneMessage.displayName = "DropzoneMessage";
|
||||
|
||||
interface DropzoneRemoveFileProps extends ButtonVariantsProps {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const DropzoneRemoveFile = forwardRef<
|
||||
HTMLButtonElement,
|
||||
DropzoneRemoveFileProps
|
||||
>(({className, ...props}, ref) => {
|
||||
const context = useDropzoneFileListContext();
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"DropzoneRemoveFile must be used within a DropzoneFileListItem",
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
onClick={context.onRemoveFile}
|
||||
type="button"
|
||||
size="icon"
|
||||
{...props}
|
||||
className={cn(
|
||||
"aria-disabled:pointer-events-none aria-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
<span className="sr-only">Remove file</span>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
DropzoneRemoveFile.displayName = "DropzoneRemoveFile";
|
||||
|
||||
interface DropzoneRetryFileProps extends ButtonVariantsProps {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const DropzoneRetryFile = forwardRef<HTMLButtonElement, DropzoneRetryFileProps>(
|
||||
({className, ...props}, ref) => {
|
||||
const context = useDropzoneFileListContext();
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"DropzoneRetryFile must be used within a DropzoneFileListItem",
|
||||
);
|
||||
}
|
||||
|
||||
const canRetry = context.canRetry;
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
aria-disabled={!canRetry}
|
||||
aria-label="retry"
|
||||
onClick={context.onRetry}
|
||||
type="button"
|
||||
size="icon"
|
||||
{...props}
|
||||
className={cn(
|
||||
"aria-disabled:pointer-events-none aria-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
<span className="sr-only">Retry</span>
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
);
|
||||
DropzoneRetryFile.displayName = "DropzoneRetryFile";
|
||||
|
||||
interface DropzoneTriggerProps
|
||||
extends React.LabelHTMLAttributes<HTMLLabelElement> {
|
||||
}
|
||||
|
||||
const DropzoneTrigger = forwardRef<HTMLLabelElement, DropzoneTriggerProps>(
|
||||
({className, children, ...props}, ref) => {
|
||||
const context = useDropzoneContext();
|
||||
if (!context) {
|
||||
throw new Error("DropzoneTrigger must be used within a Dropzone");
|
||||
}
|
||||
|
||||
const {fileStatuses, getFileMessageId} = context;
|
||||
|
||||
const fileMessageIds = useMemo(
|
||||
() =>
|
||||
fileStatuses
|
||||
.filter((file) => file.status === "error")
|
||||
.map((file) => getFileMessageId(file.id)),
|
||||
[fileStatuses, getFileMessageId],
|
||||
);
|
||||
|
||||
return (
|
||||
<label
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={cn(
|
||||
"cursor-pointer rounded-sm bg-secondary px-4 py-2 font-medium ring-offset-background transition-colors focus-within:outline-none hover:bg-secondary/80 has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-ring has-[input:focus-visible]:ring-offset-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<input
|
||||
{...context.getInputProps({
|
||||
style: {
|
||||
display: undefined,
|
||||
},
|
||||
className: "sr-only",
|
||||
tabIndex: undefined,
|
||||
})}
|
||||
aria-describedby={
|
||||
context.isInvalid
|
||||
? [context.rootMessageId, ...fileMessageIds].join(" ")
|
||||
: undefined
|
||||
}
|
||||
aria-invalid={context.isInvalid}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
},
|
||||
);
|
||||
DropzoneTrigger.displayName = "DropzoneTrigger";
|
||||
|
||||
interface InfiniteProgressProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
status: "pending" | "success" | "error";
|
||||
}
|
||||
|
||||
const valueTextMap = {
|
||||
pending: "indeterminate",
|
||||
success: "100%",
|
||||
error: "error",
|
||||
};
|
||||
|
||||
const InfiniteProgress = forwardRef<HTMLDivElement, InfiniteProgressProps>(
|
||||
({className, ...props}, ref) => {
|
||||
const done = props.status === "success" || props.status === "error";
|
||||
const error = props.status === "error";
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuetext={valueTextMap[props.status]}
|
||||
{...props}
|
||||
className={cn(
|
||||
"relative h-2 w-full overflow-hidden rounded-full bg-muted",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
// TODO: add proper done transition
|
||||
className={cn(
|
||||
"h-full w-full rounded-full bg-primary",
|
||||
done ? "translate-x-0" : "animate-infinite-progress",
|
||||
error && "bg-destructive",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
InfiniteProgress.displayName = "InfiniteProgress";
|
||||
|
||||
export {
|
||||
Dropzone,
|
||||
DropZoneArea,
|
||||
DropzoneDescription,
|
||||
DropzoneFileList,
|
||||
DropzoneFileListItem,
|
||||
DropzoneFileMessage,
|
||||
DropzoneMessage,
|
||||
DropzoneRemoveFile,
|
||||
DropzoneRetryFile,
|
||||
DropzoneTrigger,
|
||||
InfiniteProgress,
|
||||
useDropzone,
|
||||
};
|
||||
@@ -0,0 +1,296 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { Star } from 'lucide-react';
|
||||
import { motion, useInView, type SpringOptions, type UseInViewOptions } from 'motion/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const githubButtonVariants = cva(
|
||||
'cursor-pointer relative overflow-hidden will-change-transform backface-visibility-hidden transform-gpu transition-transform duration-200 ease-out hover:scale-105 group whitespace-nowrap focus-visible:outline-hidden inline-flex items-center justify-center whitespace-nowrap font-medium ring-offset-background disabled:pointer-events-none disabled:opacity-60 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'bg-zinc-950 hover:bg-zinc-900 text-white border-gray-700 dark:bg-zinc-50 dark:border-gray-300 dark:text-zinc-950 dark:hover:bg-zinc-50',
|
||||
outline: 'bg-background text-accent-foreground border border-input hover:bg-accent',
|
||||
},
|
||||
size: {
|
||||
default: 'h-8.5 rounded-md px-3 gap-2 text-[0.8125rem] leading-none [&_svg]:size-4 gap-2',
|
||||
sm: 'h-7 rounded-md px-2.5 gap-1.5 text-xs leading-none [&_svg]:size-3.5 gap-1.5',
|
||||
lg: 'h-10 rounded-md px-4 gap-2.5 text-sm leading-none [&_svg]:size-5 gap-2.5',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
interface GithubButtonProps extends React.ComponentProps<'button'>, VariantProps<typeof githubButtonVariants> {
|
||||
/** Whether to round stars */
|
||||
roundStars?: boolean;
|
||||
/** Whether to show Github icon */
|
||||
fixedWidth?: boolean;
|
||||
/** Initial number of stars */
|
||||
initialStars?: number;
|
||||
/** Class for stars */
|
||||
starsClass?: string;
|
||||
/** Target number of stars to animate to */
|
||||
targetStars?: number;
|
||||
/** Animation duration in seconds */
|
||||
animationDuration?: number;
|
||||
/** Animation delay in seconds */
|
||||
animationDelay?: number;
|
||||
/** Whether to start animation automatically */
|
||||
autoAnimate?: boolean;
|
||||
/** Callback when animation completes */
|
||||
onAnimationComplete?: () => void;
|
||||
/** Whether to show Github icon */
|
||||
showGithubIcon?: boolean;
|
||||
/** Whether to show star icon */
|
||||
showStarIcon?: boolean;
|
||||
/** Whether to show separator */
|
||||
separator?: boolean;
|
||||
/** Whether stars should be filled */
|
||||
filled?: boolean;
|
||||
/** Repository URL for actual Github integration */
|
||||
repoUrl?: string;
|
||||
/** Button text label */
|
||||
label?: string;
|
||||
/** Use in-view detection to trigger animation */
|
||||
useInViewTrigger?: boolean;
|
||||
/** In-view options */
|
||||
inViewOptions?: UseInViewOptions;
|
||||
/** Spring transition options */
|
||||
transition?: SpringOptions;
|
||||
}
|
||||
|
||||
function GithubButton({
|
||||
initialStars = 0,
|
||||
targetStars = 0,
|
||||
starsClass = '',
|
||||
fixedWidth = true,
|
||||
animationDuration = 2,
|
||||
animationDelay = 0,
|
||||
autoAnimate = true,
|
||||
className,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
showGithubIcon = true,
|
||||
showStarIcon = true,
|
||||
roundStars = false,
|
||||
separator = false,
|
||||
filled = false,
|
||||
repoUrl,
|
||||
onClick,
|
||||
label = '',
|
||||
useInViewTrigger = false,
|
||||
inViewOptions = { once: true },
|
||||
transition,
|
||||
...props
|
||||
}: GithubButtonProps) {
|
||||
const [currentStars, setCurrentStars] = useState(initialStars);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const [starProgress, setStarProgress] = useState(filled ? 100 : 0);
|
||||
const [hasAnimated, setHasAnimated] = useState(false);
|
||||
|
||||
// Format number with units
|
||||
const formatNumber = (num: number) => {
|
||||
const units = ['k', 'M', 'B', 'T'];
|
||||
|
||||
if (roundStars && num >= 1000) {
|
||||
let unitIndex = -1;
|
||||
let value = num;
|
||||
|
||||
while (value >= 1000 && unitIndex < units.length - 1) {
|
||||
value /= 1000;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
// Format to 1 decimal place if needed, otherwise show whole number
|
||||
const formatted = value % 1 === 0 ? value.toString() : value.toFixed(1);
|
||||
return `${formatted}${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
};
|
||||
|
||||
// Start animation
|
||||
const startAnimation = useCallback(() => {
|
||||
if (isAnimating || hasAnimated) return;
|
||||
|
||||
setIsAnimating(true);
|
||||
const startTime = Date.now();
|
||||
const startValue = 0; // Always start from 0 for number animation
|
||||
const endValue = targetStars;
|
||||
const duration = animationDuration * 1000;
|
||||
|
||||
const animate = () => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
// Easing function for smooth animation
|
||||
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
|
||||
|
||||
// Update star count from 0 to target with more frequent updates
|
||||
const newStars = Math.round(startValue + (endValue - startValue) * easeOutQuart);
|
||||
setCurrentStars(newStars);
|
||||
|
||||
// Update star fill progress (0 to 100)
|
||||
setStarProgress(progress * 100);
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
} else {
|
||||
setCurrentStars(endValue);
|
||||
setStarProgress(100);
|
||||
setIsAnimating(false);
|
||||
setHasAnimated(true);
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
requestAnimationFrame(animate);
|
||||
}, animationDelay * 1000);
|
||||
}, [isAnimating, hasAnimated, targetStars, animationDuration, animationDelay]);
|
||||
|
||||
// Use in-view detection if enabled
|
||||
const ref = React.useRef(null);
|
||||
const isInView = useInView(ref, inViewOptions);
|
||||
|
||||
// Reset animation state when targetStars changes
|
||||
useEffect(() => {
|
||||
setHasAnimated(false);
|
||||
setCurrentStars(initialStars);
|
||||
}, [targetStars, initialStars]);
|
||||
|
||||
// Auto-start animation or use in-view trigger
|
||||
useEffect(() => {
|
||||
if (useInViewTrigger) {
|
||||
if (isInView && !hasAnimated) {
|
||||
startAnimation();
|
||||
}
|
||||
} else if (autoAnimate && !hasAnimated) {
|
||||
startAnimation();
|
||||
}
|
||||
}, [autoAnimate, useInViewTrigger, isInView, hasAnimated, startAnimation]);
|
||||
|
||||
const navigateToRepo = () => {
|
||||
if (!repoUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Next.js compatible navigation approach
|
||||
try {
|
||||
// Create a temporary anchor element for reliable navigation
|
||||
const link = document.createElement('a');
|
||||
link.href = repoUrl;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
|
||||
// Temporarily add to DOM and click
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} catch {
|
||||
// Fallback to window.open
|
||||
try {
|
||||
window.open(repoUrl, '_blank', 'noopener,noreferrer');
|
||||
} catch {
|
||||
// Final fallback
|
||||
window.location.href = repoUrl;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (onClick) {
|
||||
onClick(event);
|
||||
return;
|
||||
}
|
||||
|
||||
if (repoUrl) {
|
||||
navigateToRepo();
|
||||
} else if (!hasAnimated) {
|
||||
startAnimation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
// Handle Enter and Space key presses for accessibility
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
|
||||
if (repoUrl) {
|
||||
navigateToRepo();
|
||||
} else if (!hasAnimated) {
|
||||
startAnimation();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(githubButtonVariants({ variant, size, className }), separator && 'ps-0')}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={repoUrl ? `Star ${label} on GitHub` : label}
|
||||
{...props}
|
||||
>
|
||||
{showGithubIcon && (
|
||||
<div
|
||||
className={cn(
|
||||
'h-full relative flex items-center justify-center',
|
||||
separator && 'w-9 bg-muted/60 border-e border-input',
|
||||
)}
|
||||
>
|
||||
<svg role="img" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{label && <span>{label}</span>}
|
||||
|
||||
{/* Animated Star Icon */}
|
||||
{showStarIcon && (
|
||||
<div className="relative inline-flex shrink-0">
|
||||
<Star className="fill-muted-foreground text-muted-foreground" aria-hidden="true" />
|
||||
<Star
|
||||
className="absolute top-0 start-0 text-yellow-400 fill-yellow-400"
|
||||
size={18}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
clipPath: `inset(${100 - starProgress}% 0 0 0)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Animated Number Counter with Ticker Effect */}
|
||||
<div className={cn('flex flex-col font-semibold relative overflow-hidden', starsClass)}>
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
...transition,
|
||||
}}
|
||||
className="tabular-nums"
|
||||
>
|
||||
<span>{currentStars > 0 && formatNumber(currentStars)}</span>
|
||||
</motion.div>
|
||||
{fixedWidth && <span className="opacity-0 h-0 overflow-hidden tabular-nums">{formatNumber(targetStars)}</span>}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export { GithubButton, githubButtonVariants };
|
||||
export type { GithubButtonProps };
|
||||
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import {Ref, useEffect, useState} from "react";
|
||||
import {Check, X} from "lucide-react";
|
||||
import {motion, AnimatePresence} from "framer-motion";
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
|
||||
interface PasswordStrengthInputProps {
|
||||
field: {
|
||||
name: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onBlur: () => void;
|
||||
ref: Ref<HTMLInputElement>;
|
||||
};
|
||||
label?: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
onValidChange?: (isValid: boolean) => void;
|
||||
|
||||
}
|
||||
|
||||
const passwordTextsFields = {
|
||||
label: "Password",
|
||||
placeholder: "Enter password",
|
||||
strength: {
|
||||
enter: "Enter a password",
|
||||
weak: "Weak password",
|
||||
medium: "Medium password",
|
||||
strong: "Strong password"
|
||||
},
|
||||
requirementsIntro: "Must contain:",
|
||||
requirements: {
|
||||
minLength: "At least 8 characters",
|
||||
number: "At least 1 number",
|
||||
lowercase: "At least 1 lowercase letter",
|
||||
uppercase: "At least 1 uppercase letter",
|
||||
specialChar: "At least 1 special character"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function PasswordStrengthInput({
|
||||
field,
|
||||
label,
|
||||
description,
|
||||
disabled, onValidChange
|
||||
}: PasswordStrengthInputProps) {
|
||||
const text = passwordTextsFields
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const password = field.value ?? "";
|
||||
|
||||
const requirements = [
|
||||
{regex: /.{8,}/, text: text.requirements.minLength},
|
||||
{regex: /[0-9]/, text: text.requirements.number},
|
||||
{regex: /[a-z]/, text: text.requirements.lowercase},
|
||||
{regex: /[A-Z]/, text: text.requirements.uppercase},
|
||||
{regex: /[^a-zA-Z0-9]/, text: text.requirements.specialChar},
|
||||
];
|
||||
|
||||
const strength = requirements.map((req) => ({
|
||||
met: req.regex.test(password),
|
||||
text: req.text,
|
||||
}));
|
||||
|
||||
const strengthScore = strength.filter((req) => req.met).length;
|
||||
|
||||
const getStrengthColor = (score: number) => {
|
||||
if (score === 0) return "bg-border";
|
||||
if (score <= 2) return "bg-red-500";
|
||||
if (score === 3) return "bg-orange-500";
|
||||
if (score === 4) return "bg-amber-500";
|
||||
return "bg-emerald-500";
|
||||
};
|
||||
|
||||
const getStrengthText = (score: number) => {
|
||||
if (score === 0) return text.strength.enter;
|
||||
if (score <= 2) return text.strength.weak;
|
||||
if (score === 3 || score === 4) return text.strength.medium;
|
||||
return text.strength.strong;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (strengthScore === requirements.length) {
|
||||
onValidChange?.(true);
|
||||
} else {
|
||||
onValidChange?.(false);
|
||||
}
|
||||
}, [strengthScore, onValidChange]);
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>{label ?? text.label}</FormLabel>
|
||||
<div className="relative">
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
placeholder={text.placeholder}
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
onFocus={() => setIsVisible(true)}
|
||||
onBlur={(e) => {
|
||||
field.onBlur();
|
||||
setIsVisible(false);
|
||||
}}
|
||||
ref={field.ref}
|
||||
name={field.name}
|
||||
disabled={disabled}
|
||||
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isVisible && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, height: 0, y: -5}}
|
||||
animate={{opacity: 1, height: "auto", y: 0}}
|
||||
exit={{opacity: 0, height: 0, y: -5}}
|
||||
transition={{duration: 0.35, ease: "easeInOut"}}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<FormDescription>
|
||||
{description ??
|
||||
`${getStrengthText(strengthScore)}. ${text.requirementsIntro}`}
|
||||
</FormDescription>
|
||||
<FormMessage/>
|
||||
|
||||
<div
|
||||
className="mb-2 h-1 w-full overflow-hidden rounded-full bg-border mt-2"
|
||||
role="progressbar"
|
||||
aria-valuenow={strengthScore}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={requirements.length}
|
||||
>
|
||||
<motion.div
|
||||
key={strengthScore}
|
||||
className={`h-full ${getStrengthColor(
|
||||
strengthScore
|
||||
)}`}
|
||||
initial={{width: 0}}
|
||||
animate={{
|
||||
width: `${(strengthScore / requirements.length) * 100}%`,
|
||||
}}
|
||||
transition={{duration: 0.5}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-1.5" aria-label="Password requirements">
|
||||
{strength.map((req, i) => (
|
||||
<li key={i} className="flex items-center gap-2">
|
||||
{req.met ? (
|
||||
<Check className="h-4 w-4 text-emerald-500"/>
|
||||
) : (
|
||||
<X className="h-4 w-4 text-muted-foreground"/>
|
||||
)}
|
||||
<span
|
||||
className={`text-xs ${
|
||||
req.met ? "text-emerald-600" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{req.text}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||