Compare commits

..
39 Commits
Author SHA1 Message Date
charlesgauthereau 98e40466a0 [FIX] (agent) - order by createdAt 2026-01-04 21:24:06 +01:00
charlesgauthereau 324d80a735 [FIX] (backup) - change in extension file 2026-01-04 19:40:08 +01:00
charlesgauthereau 6770be63b6 [FIX] - adding fileSize in upload-backup.action.ts 2026-01-03 01:06:13 +01:00
charlesgauthereau 89aa33ebd8 Updating version. 2026-01-02 15:40:20 +01:00
charlesgauthereau 619c32f09d [FEATURE] - Adding fileSize in backup model in order to display it. 2026-01-02 15:32:36 +01:00
Charles GTEandGitHub c471423775 Update README.md 2026-01-02 15:06:24 +01:00
charlesgauthereau c589b9b8b8 Updating version. Ready for 1.1.7-rc.1 2026-01-02 14:24:37 +01:00
charlesgauthereau bae9ba0973 Increase proxyClientMaxBodySize to 10g in order to accept large databases. (#2) 2026-01-02 13:55:53 +01:00
charlesgauthereau 7bfc5c1d9b Increase proxyClientMaxBodySize to 10g in order to accept large databases. 2026-01-02 13:51:57 +01:00
charlesgauthereau 8bd3b0d731 Updating README.md 2026-01-01 19:10:20 +01:00
charlesgauthereau 06cd4de141 Moving to Github folder CONTRIBUTING.md 2026-01-01 19:08:40 +01:00
charlesgauthereau 33eb2df023 Adding CITATION.cff 2026-01-01 19:07:23 +01:00
charlesgauthereau 540ecd4b60 Updating README.md 2026-01-01 18:54:06 +01:00
charlesgauthereau 237418f009 Updating README.md 2026-01-01 18:49:18 +01:00
charlesgauthereau 4447180e9e Refactoring charts in statistics in organization. 2025-12-30 16:26:28 +01:00
charlesgauthereau f111946592 Refactoring charts in statistics in organization. 2025-12-30 16:22:34 +01:00
charlesgauthereau 475e404fae Bug fixes and refactoring user dropdown. 2025-12-30 14:02:49 +01:00
charlesgauthereau 07ce5215d4 Changing Github url in stars component. 2025-12-30 13:46:00 +01:00
charlesgauthereau 9f4895fd63 Refactoring user Page in admin panel. 2025-12-30 13:42:23 +01:00
charlesgauthereau c18a9da0d3 Refactoring user Page in admin panel. 2025-12-30 13:38:01 +01:00
charlesgauthereau 8e3eb32631 Adding support light and dark in email layout, by changing the Portabase logo. 2025-12-30 11:07:45 +01:00
charlesgauthereau f0598b3f59 Adding support for github provider. 2025-12-30 10:42:51 +01:00
charlesgauthereau 011120fdf9 Patching bug on portabase.config.ts about social providers. 2025-12-30 10:27:47 +01:00
charlesgauthereau ff175e1edd Updating security.yml 2025-12-29 15:59:58 +01:00
Soluce TechnologiesandGitHub 3f56008c39 Update README.md, Updating the Gif 2025-12-29 15:51:12 +01:00
charlesgauthereau 5df95bf7c1 Updating the README.md 2025-12-29 15:42:25 +01:00
charlesgauthereau 23d52d0447 Updating the README.md 2025-12-26 18:58:08 +01:00
charlesgauthereau 795a127998 Ready for 1.1.6-rc.1 2025-12-26 16:09:11 +01:00
charlesgauthereau fd73d383f9 Adding uploading backup feature. 2025-12-26 16:08:36 +01:00
charlesgauthereau ee285df7b5 Adding useAutoRefresh on agent page. 2025-12-26 11:55:43 +01:00
charlesgauthereau 7f7f8e642f README.md 2025-12-24 09:26:49 +01:00
charlesgauthereau d9997ff5e9 Changing assets with new DA. 2025-12-23 22:10:00 +01:00
charlesgauthereau 36adcb323a Changing assets with new DA. 2025-12-23 22:06:14 +01:00
charlesgauthereau 5302060f44 Working and testing the profile module. 2025-12-23 21:44:52 +01:00
charlesgauthereau d19a46bee6 Working and testing the profile module. 2025-12-23 18:25:31 +01:00
charlesgauthereau d504866aad Patching hydratation error due to theme. 2025-12-23 09:06:06 +01:00
charlesgauthereau 601dae4909 Working on the profile module. 2025-12-22 20:32:18 +01:00
charlesgauthereau 08f2ad0521 Refactoring on image and component loading. 2025-12-22 14:23:52 +01:00
charlesgauthereau 3b356e33e7 Adding AuthCard for mobile first design and refactoring the Logo loading in the layout. 2025-12-22 12:50:40 +01:00
121 changed files with 9392 additions and 953 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ Please take a moment to review this guide. It will help you understand how to co
2. **Clone the repository**
```bash
git clone https://github.com/Soluce-Technologies/portabase
git clone https://github.com/Portabase/portabase
```
3. **Set up the development environment**
+3 -3
View File
@@ -7,9 +7,9 @@
We take security seriously and aim to support the following versions of the project with security updates:
| Version | Supported |
|-----------|--------------------|
| 1.x | ✅ Fully Supported |
| Version | Supported |
|---------|--------------------|
| Latest | ✅ Fully Supported |
---
+3
View File
@@ -24,5 +24,8 @@ jobs:
with:
fetch-depth: 0 # Fetch full history for gitleaks
- uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
with:
config-path: .gitleaks.toml # Optional, if you have a custom config
+30
View File
@@ -0,0 +1,30 @@
cff-version: 1.2.0
title: Portabase
message: "If you use this software, please cite it as below."
type: software
authors:
- family-names: Gauthereau
given-names: Charles
- family-names: Larcher
given-names: Killian
repository-code: https://github.com/Portabase/portabase
url: https://portabase.io
abstract: "Portabase is a free, open-source, self-hosted solution for database administration, providing backup and restore capabilities, scheduling, retention policies, notifications, and support for multiple storage backends. Its headless agent architecture enables connection to multiple database instances securely and efficiently."
keywords:
- docker
- kubernetes
- backups
- postgresql
- mysql
- mariadb
- devops
- database
- monitoring
- s3
- self-hosted
- system-administration
- web-ui
- agent
license: Apache-2.0
version: 1.1.6
date-released: "2026-01-01"
+139 -131
View File
@@ -6,61 +6,105 @@
<h3 align="center">Portabase</h3>
<p>
Free, open-source, and self-hosted solution for automated backup and restoration of your database instances.
Take full control of your databases with Portabase — the self-hosted, open-source platform
for automated backup, restoration, and operational management. Powered by the <strong>Portabase Agent</strong>,
every database in your infrastructure can be monitored, backed up, and managed in real time,
with zero reliance on third-party services.
</p>
<p>
Secure, lightweight, and deployable anywhere — on Docker, Kubernetes, or directly on your servers.
Designed for teams, DevOps, and enterprises who demand control, reliability, and automation at scale.
</p>
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![License: Apache](https://img.shields.io/badge/License-apache-yellow.svg)](LICENSE)
[![Docker Pulls](https://img.shields.io/docker/pulls/solucetechnologies/portabase?color=brightgreen)](https://hub.docker.com/r/solucetechnologies/portabase)
[![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-lightgrey)](https://github.com/RostislavDugin/postgresus)
[![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-lightgrey)](https://github.com/Portabase/portabase)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-336791?logo=postgresql&logoColor=white)](https://www.postgresql.org/)
[![MySQL](https://img.shields.io/badge/MySQL-4479A1?logo=mysql&logoColor=white)](https://www.mysql.com/)
[![MariaDB](https://img.shields.io/badge/MariaDB-003545?logo=mariadb&logoColor=white)](https://mariadb.org/)
[![Self Hosted](https://img.shields.io/badge/self--hosted-yes-brightgreen)](https://github.com/RostislavDugin/postgresus)
[![Self Hosted](https://img.shields.io/badge/self--hosted-yes-brightgreen)](https://github.com/Portabase/portabase)
[![Open Source](https://img.shields.io/badge/open%20source-❤️-red)](https://github.com/Portabase/portabase)
<p>
<strong>
<a href="https://portabase.io">Documentation</a> •
<a href="https://www.youtube.com/watch?v=D9uFrGxLc4s">Demo</a> •
<a href="https://www.youtube.com/watch?v=hvLbX5LN1UE">Demo</a> •
<a href="#installation">Installation</a> •
<a href="#contributing">Contributing</a> •
<a href="https://github.com/Soluce-Technologies/portabase/issues/new?labels=bug&template=bug-report---.md">Report Bug</a>
<a href="https://github.com/Soluce-Technologies/portabase/issues/new?labels=enhancement&template=feature-request---.md">Request Feature</a>
<a href="https://github.com/Portabase/portabase/issues/new?labels=bug&template=bug-report---.md">Report Bug</a> •
<a href="https://github.com/Portabase/portabase/issues/new?labels=enhancement&template=feature-request---.md">Request Feature</a>
</strong>
</p>
![portabase-ezgif com-video-to-gif-converter](https://github.com/user-attachments/assets/e3929e58-fefd-44cc-ab26-6f8e1a860c43)
![portabase-dashboard](https://github.com/user-attachments/assets/f5ec7204-79c0-43ac-ba17-f2ed682c39d6)
</div>
## 📚 Table of Contents
- [About The Project](#-about-the-project)
- [Getting Started](#-getting-started)
- [Usage](#-usage)
- [Roadmap](#-roadmap)
- [Contributing](#-contributing)
- [License](#-license)
- [Contact](#-contact)
- [Acknowledgments](#-acknowledgments)
---
## ✨ 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)
integrates seamlessly with [Portabase agents](https://github.com/Portabase/agent-portabase) for managing operations
securely and efficiently.
### 🔧 Built With
- [![NextJS][NextJS]][NextJS-url] (v16 with App Router)
- [![NextJS][NextJS]][NextJS-url]
- [![Drizzle][Drizzle]][Drizzle-url]
- [![ShadcnUI][ShadcnUI]][ShadcnUI-url]
- [![BetterAuth][BetterAuth]][BetterAuth-url]
- [![Docker][Docker]][Docker-url]
---
## 📦 Features
### 🗄️ Supported databases
- PostgreSQL
- MySQL
- MariaDB
### ⏱️ Scheduled backups
- Cron-based scheduling for full control
- Manual trigger support for on-demand backups
### 💾 Storage backends
- On-premise storage: Backups are stored directly on your VPS or server
- Cloud storage: S3-compatible backends
supported ([documentation](https://portabase.io/docs/dashboard/advanced/storage/s3))
- Full data ownership: No third-party access — your data stays under your control
### 🔔 Smart notifications
- Multi-channel delivery: Email, Slack, Discord, webhooks
- Real-time alerts: Immediate feedback on success and failure
- Custom alert policies: Database-level notification rules
- Team-ready: Designed for DevOps, on-call, and incident workflows
### 👥 Built for team environments
- Workspaces: Organize databases, notification channels, and storage backends by organization and project
- Access control: Fine-grained, role-based permissions on all resources
- Role management: Member, admin, and owner roles at both system and organization levels
### 🐳 Self-hosted & secure
- Containerized deployment: Docker-based setup for predictable installation and operations
- Privacy by design: All data remains within your own infrastructure
- Open source: Apache 2.0 licensed — fully auditable codebase
### 🤖 Portabase Agent ([details](https://github.com/Portabase/agent-portabase))
- Headless architecture: Runs locally on your infrastructure to manage backups and database operations
- Multi-target support: Single agent can connect to multiple databases across different servers
- Lightweight & efficient: Minimal resource footprint while providing full operational control
- Secure communication: Encrypted channels between agent and central dashboard
---
@@ -68,9 +112,15 @@ GitHub Repository: [Portabase](https://github.com/Soluce-Technologies/portabase)
### Installation
You have 3 ways to install **Portabase**:
- Automated CLI (recommended) - [details](https://portabase.io/docs/cli)
- Docker Compose setup - [details](https://portabase.io/docs/dashboard/setup)
- Kubernetes with Helm (soon)
Ensure Docker is installed on your machine before getting started.
### Option 1: Docker Compose Setup
### Docker Compose Setup
Create a `docker-compose.yml` file with the following configuration:
@@ -86,7 +136,7 @@ services:
ports:
- '8887:80'
environment:
- TIME_ZONE="Europe/Paris"
- TZ="Europe/Paris"
volumes:
- portabase-private:/app/private
depends_on:
@@ -122,95 +172,9 @@ docker compose up -d
```
If you use reverse proxy like
Traefik : [Check this link](https://portabase.io/docs/portabase/advanced-topics/reverse-proxy)
Traefik : [Check this link](https://portabase.io/docs/dashboard/advanced/reverse-proxy)
### Option 2: Locally (Development)
1. Clone the repository:
```bash
git clone https://github.com/Soluce-Technologies/portabase
cd portabase
```
2. Start the development environment:
```bash
docker compose up
```
---
## 🛠️ Usage
Portabase provides a web dashboard to manage your database instances and backups.
It currently supports:
- **PostgreSQL**
- **MySQL**
### Process
1. **Access the dashboard** Open `http://localhost:8887` in your browser.
2. **Sign up** Register the first user, who will automatically have the **Admin** role in the default workspace.
3. **Add your first agent** Follow [this guide](https://github.com/Soluce-Technologies/agent-portabase) for setup
instructions.
4. **Create organizations and projects** Link your databases to projects to enable backups and restores.
5. **Configure backup policies** Define schedules (hourly, daily, weekly, or monthly) and retention rules.
6. **Choose a storage provider** Select where backups will be stored (local, S3, etc.).
7. **Save and start** Portabase validates your configuration and starts automated backups based on your defined
policies.
---
## 🗺️ Roadmap
- [ ] Add changelog
- [ ] Enhance documentation
- [ ] Implement testing procedures
- [ ] Include release file
- [ ] Improve security
- [ ] Enhance UX/UI
- [ ] Improve workspace management system
- [x] Migrate to Drizzle ORM
- [ ] Add multi-language support
- [ ] Extend multi-database support:
- [x] PostgreSQL
- [ ] MongoDB
- [x] MySQL
- [x] MariaDB
Check out [open issues](https://github.com/Soluce-Technologies/portabase/issues) for more.
---
## 🤝 Contributing
Contributions are welcome and appreciated! Here's how to get started:
1. Fork the repository
2. Create a new branch:
```bash
git checkout -b feature/YourFeature
```
3. Commit your changes:
```bash
git commit -m "Add YourFeature"
```
4. Push to the branch:
```bash
git push origin feature/YourFeature
```
5. Open a pull request
Give the project a ⭐ if you like it!
### Top Contributors
[![Contributors](https://contrib.rocks/image?repo=Soluce-Technologies/portabase)](https://github.com/Soluce-Technologies/portabase/graphs/contributors)
---
## Developer Notes
### Environment Variables
#### Environment Variables
```yml
# Environment
@@ -253,19 +217,67 @@ RETENTION_CRON="* * * * *"
```
To get more information about env variables, check
that [link](https://portabase.io/docs/portabase/advanced-topics/environment)
that [link](https://portabase.io/docs/dashboard/advanced/environment)
### Semantic Versioning
### Locally (Development)
Use the following format for Docker image versioning:
1. Clone the repository:
```bash
git clone https://github.com/Portabase/portabase
cd portabase
```
2. Start the development database for Portabase service:
```bash
docker compose up
```
```bash
major.minor.patch-rc.release
# Example: 1.0.0-rc.1
3. Start the Next.js app:
```bash
make up
```
major.minor.patch-rc.release-tag
# Example: 1.0.0-rc.1-dev
```
---
## 🛠️ Usage
Once the installation process is done, follow the steps to configure your instance.
### Dashboard configuration process
1. **Access the dashboard** Open `http://localhost:8887` in your browser.
2. **Sign up** Register the first user, who will automatically have the **Admin** role in the default workspace.
3. **Add your first agent** Follow [this guide](https://github.com/Portabase/agent-portabase) for setup
instructions.
4. **Create organizations and projects** Link your databases to projects to enable backups and restores.
5. **Configure backup policies** Define schedules (hourly, daily, weekly, or monthly) and retention rules.
6. **Choose a storage provider** Select where backups will be stored (local, S3, etc.).
7. **Save and start** Portabase validates your configuration and starts automated backups based on your defined
policies.
---
## 🤝 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 features/your-feature
```
3. Commit your changes:
```bash
git commit -m "Add YourFeature"
```
4. Push to the branch:
```bash
git push origin features/your-feature
```
5. Open a pull request
### Top Contributors
[![Contributors](https://contrib.rocks/image?repo=Portabase/portabase)](https://github.com/Portabase/portabase/graphs/contributors)
---
@@ -275,18 +287,13 @@ 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!
Give the project a ⭐ if you like it!
[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
@@ -304,5 +311,6 @@ Thanks to all contributors and the open-source community!
[Drizzle-url]: https://orm.drizzle.team/
[ShadcnUI-url]: https://ui.shadcn.com/
[Docker-url]: https://www.docker.com/
+9 -7
View File
@@ -1,23 +1,25 @@
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import {CardContent, CardHeader} from "@/components/ui/card";
import { TooltipProvider } from "@/components/ui/tooltip";
import { ForgotPasswordForm } from "@/components/wrappers/auth/login/forgot-password-form/forgot-password-form";
import {TooltipProvider} from "@/components/ui/tooltip";
import {ForgotPasswordForm} from "@/components/wrappers/auth/login/forgot-password-form/forgot-password-form";
import {CardAuth} from "@/features/layout/card-auth";
export default async function RoutePage(props: { searchParams: Promise<{ callbackUrl: string | undefined }> }) {
return (
<TooltipProvider>
<Card className="w-full">
<CardAuth className="w-full">
<CardHeader>
<div className="grid gap-2 text-center mb-2">
<h1 className="text-3xl font-bold">Reset password</h1>
<p className="text-balance text-muted-foreground">Enter your email address and we'll send you a link to reset your password.</p>
<p className="text-balance text-muted-foreground">Enter your email address and we'll send you a
link to reset your password.</p>
</div>
</CardHeader>
<CardContent>
<ForgotPasswordForm />
<ForgotPasswordForm/>
</CardContent>
</Card>
</CardAuth>
</TooltipProvider>
);
}
+11 -10
View File
@@ -1,8 +1,9 @@
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TooltipProvider } from "@/components/ui/tooltip";
import { GuardForm } from "@/components/wrappers/auth/guard/guard-form";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import {CardContent, CardHeader} from "@/components/ui/card";
import {TooltipProvider} from "@/components/ui/tooltip";
import {GuardForm} from "@/components/wrappers/auth/guard/guard-form";
import {cookies} from "next/headers";
import {redirect} from "next/navigation";
import {CardAuth} from "@/features/layout/card-auth";
export default async function GuardPage() {
@@ -13,20 +14,20 @@ export default async function GuardPage() {
redirect("/login");
}
return (
<TooltipProvider>
<Card className="w-full max-w-md">
<CardAuth className="w-full max-w-md">
<CardHeader>
<div className="grid gap-2 text-center mb-2">
<h1 className="text-3xl font-bold">Two-factor verification</h1>
<p className="text-balance text-muted-foreground">Please enter the verification code generated by your authentication app.</p>
<p className="text-balance text-muted-foreground">Please enter the verification code generated
by your authentication app.</p>
</div>
</CardHeader>
<CardContent>
<GuardForm />
<GuardForm/>
</CardContent>
</Card>
</CardAuth>
</TooltipProvider>
);
}
+8 -11
View File
@@ -3,9 +3,10 @@ import {Metadata} from "next";
import {SUPPORTED_PROVIDERS} from "../../../portabase.config";
import {SocialAuthButtons} from "@/components/wrappers/auth/social-buttons";
import {TooltipProvider} from "@/components/ui/tooltip";
import {Card, CardContent, CardHeader} from "@/components/ui/card";
import {CardContent, CardHeader} from "@/components/ui/card";
import Link from "next/link";
import {Separator} from "@/components/ui/separator";
import {CardAuth} from "@/features/layout/card-auth";
export const metadata: Metadata = {
title: "Login",
@@ -15,7 +16,7 @@ export default async function SignInPage() {
return (
<TooltipProvider>
<Card className="w-full">
<CardAuth className="w-full">
<CardHeader>
<div className="grid gap-2 text-center mb-2">
<h1 className="text-3xl font-bold">Login</h1>
@@ -23,18 +24,16 @@ export default async function SignInPage() {
</div>
</CardHeader>
<CardContent className="space-y-4">
<LoginForm />
<LoginForm/>
{SUPPORTED_PROVIDERS.filter((p) => !p.isManual).length > 0 && (
{SUPPORTED_PROVIDERS.filter((p) => !p.isManual && p.isActive).length > 0 && (
<>
<div className="relative my-4 flex items-center justify-center overflow-hidden">
<Separator/>
<div className="px-2 text-center bg-card text-sm">OR</div>
<div className="px-2 text-center text-sm">OR</div>
<Separator/>
</div>
<SocialAuthButtons />
<SocialAuthButtons providers={SUPPORTED_PROVIDERS}/>
</>
)}
@@ -45,11 +44,9 @@ export default async function SignInPage() {
</Link>
</div>
</CardContent>
</Card>
</CardAuth>
</TooltipProvider>
)
}
+56 -12
View File
@@ -1,15 +1,59 @@
import {PageParams} from "@/types/next";
import {Metadata} from "next";
import {ResetPasswordSection} from "@/components/wrappers/auth/reset-password/reset-password-section";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TooltipProvider } from "@/components/ui/tooltip";
import { ResetPasswordForm } from "@/components/wrappers/auth/login/reset-password-form/reset-password-form";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth/auth";
import { Avatar, AvatarImage, AvatarFallback } from "@radix-ui/react-avatar";
export const metadata: Metadata = {
title: "Reset Password",
};
export default async function RoutePage(props: { searchParams: Promise<{ token: string | undefined }> }) {
const { token } = await props.searchParams;
if (!token) {
return redirect(`/login?error=invalid_or_expired_token`);
}
const verification = await (await auth.$context).internalAdapter.findVerificationValue(`reset-password:${token}`);
if (!verification || verification.expiresAt < new Date()) {
return redirect(`/login?error=invalid_or_expired_token`);
}
const user = await (await auth.$context).internalAdapter.findUserById(verification.value);
export default async function RoutePage(props: PageParams<{}>) {
return (
<div className="mx-auto grid w-full gap-6">
<ResetPasswordSection/>
</div>
)
}
<TooltipProvider>
<Card className="w-full max-w-md shadow-lg">
<CardHeader className="space-y-4">
<div className="space-y-1 text-center">
<h1 className="text-2xl font-bold tracking-tight">Set a new password</h1>
<p className="text-sm text-muted-foreground text-balance">Please enter your new password below.</p>
</div>
<div className="flex flex-col items-center space-y-2 text-center">
<Avatar className="relative flex h-16 w-16 shrink-0 overflow-hidden rounded-full border">
<AvatarImage src={user!.image ?? ""} alt={user!.name} className="aspect-square h-full w-full object-cover" />
<AvatarFallback className="flex h-full w-full items-center justify-center rounded-full bg-muted text-2xl font-medium text-muted-foreground">
{user!.name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2)}
</AvatarFallback>
</Avatar>
<div className="flex flex-col items-center">
<div className="font-semibold text-lg">{user!.name}</div>
<div className="text-sm text-muted-foreground">{user!.email}</div>
</div>
</div>
</CardHeader>
<CardContent>
<ResetPasswordForm />
</CardContent>
</Card>
</TooltipProvider>
);
}
@@ -1,9 +1,9 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {db} from "@/db";
import {DataTable} from "@/components/wrappers/common/table/data-table";
import {usersColumnsAdmin} from "@/components/wrappers/dashboard/admin/users/columns-users";
import {isNull} from "drizzle-orm";
import {desc, isNull} from "drizzle-orm";
import {AdminUserList} from "@/components/wrappers/dashboard/admin/users/admin-user-list";
import {AdminUserAddModal} from "@/components/wrappers/dashboard/admin/users/admin-user-add-modal";
export default async function RoutePage(props: PageParams<{}>) {
@@ -11,7 +11,14 @@ export default async function RoutePage(props: PageParams<{}>) {
where: (fields) => isNull(fields.deletedAt),
with: {
accounts: true
}
},
orderBy: (fields) => desc(fields.createdAt),
});
const organizations = await db.query.organization.findMany({
with: {
members: true,
},
});
return (
@@ -19,14 +26,16 @@ export default async function RoutePage(props: PageParams<{}>) {
<PageHeader className="flex flex-col">
<div className="flex justify-between">
<PageTitle className="mb-3">Active users</PageTitle>
<PageActions>
<AdminUserAddModal organizations={organizations}/>
</PageActions>
</div>
</PageHeader>
<PageContent className="flex flex-col gap-5">
<DataTable
enableSelect={false}
columns={usersColumnsAdmin}
data={users}/>
<AdminUserList users={users}/>
</PageContent>
</Page>
);
}
@@ -2,21 +2,16 @@ import Link from "next/link";
import {GearIcon} from "@radix-ui/react-icons";
import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
import {formatDateLastContact} from "@/utils/date-formatting";
import {buttonVariants} from "@/components/ui/button";
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
import {DatabaseCard} from "@/components/wrappers/dashboard/projects/project-card/project-database-card";
import {AgentCardKey} from "@/components/wrappers/dashboard/agent/agent-card-key/agent-card-key";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {eq} from "drizzle-orm";
import {notFound} from "next/navigation";
import {ButtonDeleteAgent} from "@/components/wrappers/dashboard/agent/button-delete-agent/button-delete-agent";
import {capitalizeFirstLetter} from "@/utils/text";
import {Server} from "lucide-react";
import {generateEdgeKey} from "@/utils/edge_key";
import {getServerUrl} from "@/utils/get-server-url";
import {AgentContentPage} from "@/components/wrappers/dashboard/agent/agent-content";
export default async function RoutePage(props: PageParams<{ agentId: string }>) {
@@ -54,42 +49,10 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
<PageDescription className="mt-5 sm:mt-0">{agent.description}</PageDescription>
)}
<PageContent className="flex flex-col w-full h-full justify-between gap-6">
<div className="flex flex-col sm:flex-row sm:justify-between gap-6 ">
<Card className="w-full sm:w-auto flex-1">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Databases</CardTitle>
<Server className="h-4 w-4 text-muted-foreground"/>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{agent.databases.length}</div>
<p className="text-xs text-muted-foreground">Databases linked to this agent</p>
</CardContent>
</Card>
<Card className="w-full sm:w-auto flex-1">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Last contact</CardTitle>
<Server className="h-4 w-4 text-muted-foreground"/>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatDateLastContact(agent.lastContact)}</div>
<p className="text-xs text-muted-foreground">Last contact with agent</p>
</CardContent>
</Card>
</div>
<Card className="w-full sm:w-auto flex-1 ">
<CardHeader className="font-bold text-xl">
Edge Key
</CardHeader>
<CardContent>
<AgentCardKey
edgeKey={edgeKey}
/>
</CardContent>
</Card>
<CardsWithPagination cardsPerPage={4} numberOfColumns={2} data={agent.databases}
cardItem={DatabaseCard}/>
<AgentContentPage
agent={agent}
edgeKey={edgeKey}
/>
</PageContent>
</Page>
)
@@ -7,7 +7,7 @@ import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/
import {notFound} from "next/navigation";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {eq, not} from "drizzle-orm";
import {desc, eq, not} from "drizzle-orm";
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
import {Metadata} from "next";
@@ -21,7 +21,8 @@ export default async function RoutePage(props: PageParams<{}>) {
where: not(eq(drizzleDb.schemas.agent.isArchived, true)),
with: {
databases: true
}
},
orderBy: (fields) => desc(fields.createdAt),
});
console.log(agents);
@@ -1,41 +0,0 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {notFound} from "next/navigation";
// import {RestoreForm} from "@/components/wrappers/dashboard/database/restore-form";
import {currentUser} from "@/lib/auth/current-user";
import {db} from "@/db";
import {eq} from "drizzle-orm";
import * as drizzleDb from "@/db";
export default async function RoutePage(
props: PageParams<{
databaseId: string;
}>
) {
const {databaseId} = await props.params;
const user = await currentUser();
if (!user) notFound();
const [dbToRestore] = await db.select().from(drizzleDb.schemas.database).where(eq(drizzleDb.schemas.database.id, databaseId));
if (!dbToRestore) {
notFound();
}
const dbsOfSameType = await db.select().from(drizzleDb.schemas.database).where(eq(drizzleDb.schemas.database.dbms!, dbToRestore.dbms!));
const successfulBackups = await db.select().from(drizzleDb.schemas.backup).where(eq(drizzleDb.schemas.backup.status, "success"));
return (
<Page>
<PageHeader>
<PageTitle>Restore {dbToRestore.name}</PageTitle>
</PageHeader>
<PageContent>
{/*<RestoreForm databaseToRestore={dbToRestore} databases={dbsOfSameType} backups={successfulBackups}/>*/}
</PageContent>
</Page>
);
}
@@ -14,6 +14,7 @@ import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/ret
import {capitalizeFirstLetter} from "@/utils/text";
import {AlertPolicyModal} from "@/components/wrappers/dashboard/database/alert-policy/alert-policy-modal";
import {getOrganizationChannels} from "@/db/services/notification-channel";
import {ImportModal} from "@/components/wrappers/dashboard/database/import/import-modal";
export default async function RoutePage(props: PageParams<{
projectId: string;
@@ -107,6 +108,7 @@ export default async function RoutePage(props: PageParams<{
<CronButton database={dbItem}/>
<AlertPolicyModal database={dbItem} notificationChannels={activeOrganizationChannels}
organizationId={organization.id}/>
<ImportModal database={dbItem}/>
</div>
<div className="flex items-center gap-2">
<BackupButton disable={isAlreadyBackup} databaseId={databaseId}/>
@@ -78,9 +78,6 @@ export default async function RoutePage(props: PageParams<{}>) {
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
const Placeholder = ({text}: { text: string }) => (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">{text}</div>
);
return (
<Page>
@@ -125,31 +122,12 @@ export default async function RoutePage(props: PageParams<{}>) {
</div>
<div className="flex flex-col md:flex-row gap-4">
<Card className="w-full">
<CardHeader>
<CardTitle>Evolution of the number of backups</CardTitle>
</CardHeader>
<CardContent>
{sortedBackupsEvolution.length > 0 ? (
<EvolutionLineChart data={sortedBackupsEvolution}/>
) : (
<Placeholder text="No backup data available"/>
)}
</CardContent>
</Card>
<EvolutionLineChart
data={sortedBackupsEvolution}
/>
<Card className="w-full">
<CardHeader>
<CardTitle>Success rate of backups</CardTitle>
</CardHeader>
<CardContent>
{backupsRate.length > 0 ? (
<PercentageLineChart data={backupsRate}/>
) : (
<Placeholder text="No backup rate data available"/>
)}
</CardContent>
</Card>
<PercentageLineChart
data={backupsRate}/>
</div>
</PageContent>
</Page>
+13 -13
View File
@@ -1,26 +1,26 @@
import React from "react";
import React, {ReactNode} from "react";
import {redirect} from "next/navigation";
import {SidebarInset, SidebarProvider} from "@/components/ui/sidebar";
import {AppSidebar} from "@/components/wrappers/dashboard/common/sidebar/app-sidebar";
import {Header} from "@/features/layout/Header";
import {currentUser} from "@/lib/auth/current-user";
import {ThemeMetaUpdater} from "@/features/browser/theme-meta-updater";
export default async function Layout({children}: { children: React.ReactNode }) {
export default async function Layout({children}: { children: ReactNode }) {
const user = await currentUser();
if (!user) redirect("/login");
return (
<>
<SidebarProvider>
<div className="flex flex-col lg:flex-row w-full">
<AppSidebar/>
<SidebarInset>
<Header/>
<main className="h-full">{children}</main>
</SidebarInset>
</div>
</SidebarProvider>
</>
<SidebarProvider>
<div className="flex flex-col lg:flex-row w-full">
<ThemeMetaUpdater/>
<AppSidebar/>
<SidebarInset>
<Header/>
<main className="h-full">{children}</main>
</SidebarInset>
</div>
</SidebarProvider>
);
}
+5 -3
View File
@@ -106,7 +106,7 @@ export async function POST(
if (status === "success") {
const file = formData.get("file") as File | null;
const extension = formData.get("extension") as string | null;
if (!aesKeyHex || !ivHex) {
return NextResponse.json({error: "Missing fields"}, {status: 400});
}
@@ -118,12 +118,13 @@ export async function POST(
{status: 400}
);
}
const fileExtension = getFileExtension(database.dbms)
const fileSizeBytes = file.size;
// const fileExtension = '.' + (file.name.split('.').pop()?.toLowerCase() || '');
const fileExtension = extension ? extension : getFileExtension(database.dbms)
const decryptedFile = await decryptedDump(file, aesKeyHex, ivHex, fileExtension);
const uuid = uuidv4();
const fileName = `${uuid}${fileExtension}`;
const buffer = Buffer.from(await decryptedFile.arrayBuffer());
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
if (!settings) {
throw new Error("System settings not found.");
@@ -149,6 +150,7 @@ export async function POST(
.update(drizzleDb.schemas.backup)
.set(withUpdatedAt({
file: fileName,
fileSize: fileSizeBytes,
status: 'success',
}))
.where(eq(drizzleDb.schemas.backup.id, backup.id));
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

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

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 20 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

+8 -5
View File
@@ -16,19 +16,22 @@ export const metadata: Metadata = {
description: process.env.PROJECT_DESCRIPTION ?? undefined,
};
export default function RootLayout({
children,
}: Readonly<{
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<meta name="apple-mobile-web-app-title" content="Portabase"/>
<meta name="apple-mobile-web-app-title" content={title}/>
</head>
<body className={cn(inter.className, "h-full")}>
<ConsoleSilencer/>
<Providers>{children}</Providers>
<Providers>
{children}
</Providers>
</body>
</html>
);
+13 -6
View File
@@ -1,10 +1,12 @@
"use client";
import { PropsWithChildren, Suspense } from "react";
import { ThemeProvider } from "@/features/theme/theme-provider";
import {PropsWithChildren, Suspense} from "react";
import {ThemeProvider} from "@/features/theme/theme-provider";
import {Toaster} from "@/components/ui/sonner";
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
import {ThemeMetaUpdaterRoot} from "@/features/browser/theme-meta-updater-root";
import { Toaster } from "@/components/ui/sonner";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
export type ProviderProps = PropsWithChildren<{}>;
const queryClient = new QueryClient();
@@ -12,9 +14,14 @@ const queryClient = new QueryClient();
export const Providers = (props: ProviderProps) => {
return (
<Suspense fallback={null}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
>
<ThemeMetaUpdaterRoot/>
<QueryClientProvider client={queryClient}>
<Toaster />
<Toaster/>
{props.children}
</QueryClientProvider>
</ThemeProvider>
+3 -1
View File
@@ -43,8 +43,10 @@ const nextConfig: NextConfig = {
},
experimental: {
serverActions: {
bodySizeLimit: "50mb",
bodySizeLimit: "10gb",
},
proxyClientMaxBodySize: '10gb',
},
async headers() {
return [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "portabase",
"version": "1.1.5",
"version": "1.1.8",
"private": true,
"scripts": {
"dev": "next dev --turbopack -p 8887",
+14 -13
View File
@@ -1,9 +1,9 @@
import {Chrome, KeyRound, LucideIcon} from "lucide-react";
export interface AuthProviderConfig {
id: "google" | "github" | "credential";
icon: LucideIcon;
isActive: boolean;
icon: string;
isManual?: boolean;
credentials?: {
clientId: string;
@@ -80,26 +80,27 @@ export const PORTABASE_DEFAULT_SETTINGS = {
export const SUPPORTED_PROVIDERS: AuthProviderConfig[] = [
{
id: "google",
icon: Chrome,
icon: "hugeicons:chrome",
isActive: Boolean(process.env.AUTH_GOOGLE_METHOD),
// isManual: true,
credentials: {
clientId: process.env.GOOGLE_CLIENT_ID || "",
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
clientId: process.env.AUTH_GOOGLE_ID || "",
clientSecret: process.env.AUTH_GOOGLE_SECRET || "",
},
},
/*
{
id: "github",
name: "GitHub",
icon: Github,
description: "Connexion via GitHub",
icon: "iconoir:github",
isActive: Boolean(process.env.AUTH_GITHUB_METHOD),
credentials: {
clientId: process.env.GITHUB_CLIENT_ID || "",
clientSecret: process.env.GITHUB_CLIENT_SECRET || "",
clientId: process.env.AUTH_GITHUB_ID || "",
clientSecret: process.env.AUTH_GITHUB_SECRET || "",
},
},*/
},
{
id: "credential",
icon: KeyRound,
isActive: true,
icon: "proicons:key",
isManual: true,
credentials: {
clientId: "",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,38 @@
import * as React from "react";
import EmailLayout from "../email-layout";
import {Heading, Text, Section, Button} from "@react-email/components";
import {getServerUrl} from "@/utils/get-server-url";
interface EmailCreateUserProps {
firstname?: string;
token: string;
}
export const EmailForgotPassword = ({firstname, token}: EmailCreateUserProps) => {
const baseUrl = getServerUrl();
return (
<EmailLayout preview="Portabase - Forgot Password">
<Heading className="mx-0 my-[30px] p-0 text-center font-normal text-[24px] text-black">
Hello <strong>{firstname}</strong>,
</Heading>
<Text className="text-[14px] text-black leading-[24px]">
We received a request to reset the password for your account associated with this email.
</Text>
<Text className="text-[14px] text-black leading-[24px]">
If you did not request this password reset, please ignore this email. Your current password
will remain unchanged. If this seems suspicious, please contact your administrator.
</Text>
<Section className="mt-[32px] mb-[32px] text-center">
<Button
className="rounded bg-[#000000] px-5 py-3 text-center font-semibold text-[12px] text-white no-underline"
href={`${baseUrl}/reset-password?token=${token}`}
>
Reset my password
</Button>
</Section>
</EmailLayout>
);
};
export default EmailForgotPassword;
@@ -0,0 +1,32 @@
import * as React from "react";
import EmailLayout from "../email-layout";
import {Heading, Text} from "@react-email/components";
interface EmailCreateUserProps {
firstname?: string;
os: string;
browser: string;
ipAddress?: string;
}
export const EmailNewLogin = ({firstname, ipAddress, os, browser}: EmailCreateUserProps) => {
return (
<EmailLayout preview="Portabase - New Login">
<Heading className="mx-0 my-[30px] p-0 text-center font-normal text-[24px] text-black">
Hello <strong>{firstname}</strong>,
</Heading>
<Text className="text-[14px] text-black leading-[24px]">
We detected a new login to your account.
</Text>
<Text className="text-[14px] text-black leading-[24px]">
<strong>Login details:</strong>
<br/>
Device: {os} - {browser}
<br/>
IP Address: {ipAddress}
</Text>
</EmailLayout>
);
};
export default EmailNewLogin;
@@ -0,0 +1,42 @@
import * as React from "react";
import EmailLayout from "./email-layout";
import {Heading, Text, Section, Button} from "@react-email/components";
import {getServerUrl} from "@/utils/get-server-url";
import {env} from "@/env.mjs";
interface EmailCreateUserProps {
email: string;
password: string;
}
export const EmailCreateUser = ({email, password}: EmailCreateUserProps) => {
const baseUrl = getServerUrl();
return (
<EmailLayout preview="Portabase Dashboard">
<Heading className="mx-0 my-[30px] p-0 text-center font-normal text-[24px] text-black">
Your account on {env.PROJECT_NAME} has just been created!
</Heading>
<Text className="text-[14px] text-black leading-[24px]">
<strong>Email: </strong>{email}
</Text>
<Text className="text-[14px] text-black leading-[24px]">
<strong>Default password: </strong>{password}
</Text>
<Section className="mt-[32px] mb-[32px] text-center">
<Button
className="rounded bg-[#000000] px-5 py-3 text-center font-semibold text-[12px] text-white no-underline"
href={baseUrl}
>
Sign in
</Button>
</Section>
</EmailLayout>
);
};
export default EmailCreateUser;
+13 -5
View File
@@ -10,11 +10,19 @@ export const EmailLayout = ({ children, preview }: PropsWithChildren<{ preview?:
<Tailwind>
<Html>
<Head />
{preview ? <Preview>{preview}</Preview> : <Preview>Please check your mails</Preview>}
<Body className="bg-gray-100 py-4" style={{ fontFamily: "Arial, sans-serif" }}>
<Container className="bg-white border border-gray-200 p-12">
<Img src={`${baseUrl}/images/logo-black.png`} width="200" height="auto" alt="Logo" />
<Section>{children}</Section>
<Body className="mx-auto my-auto bg-white px-2 font-sans">
{preview ? <Preview>{preview}</Preview> : <Preview>Please check your mails</Preview>}
<Container className="mx-auto my-[40px] max-w-[465px] rounded border border-[#eaeaea] border-solid p-[20px]">
<Section className="mt-[32px]">
<Img
src={`${baseUrl}/images/logo.png`}
width="50"
height="auto"
alt="Logo"
className="mx-auto my-0"
/>
</Section>
{children}
</Container>
</Body>
</Html>
@@ -1,30 +0,0 @@
import * as React from "react";
import EmailLayout from "./email-layout";
import {Text, Section, Button} from "@react-email/components";
export interface EmailResetPasswordProps {
url: string;
}
export const EmailResetPassword = ({url}: EmailResetPasswordProps) => {
return (
<EmailLayout preview="Email for password reset of your Portabase account">
<Text className="text-base font-bold ">Hello !</Text>
<Text className="text-base font-light ">You are receiving this email because we
received a password reset request for your account.</Text>{" "}
<Section className="mt-[32px] mb-[32px] text-center">
<Button
className="rounded bg-[#000000] px-5 py-3 text-center font-semibold text-[12px] text-white no-underline"
href={url}
>
Reset Password
</Button>
</Section>
<Text className="text-base font-light ">If you did not request a password reset, no
further action is required.</Text>
<Text className="text-base font-light ">Regards,<br/>Portabase</Text>
</EmailLayout>
);
};
export default EmailResetPassword;
+850
View File
@@ -0,0 +1,850 @@
"use client"
import {cn} from "@/lib/utils";
import {
createContext,
forwardRef,
useCallback,
useContext,
useId,
useMemo,
useReducer,
useState,
} from "react";
import {
Accept,
FileRejection,
useDropzone as rootUseDropzone,
} from "react-dropzone";
import {Button, ButtonVariantsProps} from "@/components/ui/button";
type DropzoneResult<TUploadRes, TUploadError> =
| {
status: "pending";
}
| {
status: "error";
error: TUploadError;
}
| {
status: "success";
result: TUploadRes;
};
export type FileStatus<TUploadRes, TUploadError> = {
id: string;
fileName: string;
file: File;
tries: number;
} & (
| {
status: "pending";
result?: undefined;
error?: undefined;
}
| {
status: "error";
error: TUploadError;
result?: undefined;
}
| {
status: "success";
result: TUploadRes;
error?: undefined;
}
);
const fileStatusReducer = <TUploadRes, TUploadError>(
state: FileStatus<TUploadRes, TUploadError>[],
action:
| {
type: "add";
id: string;
fileName: string;
file: File;
}
| {
type: "remove";
id: string;
}
| ({
type: "update-status";
id: string;
} & DropzoneResult<TUploadRes, TUploadError>),
): FileStatus<TUploadRes, TUploadError>[] => {
switch (action.type) {
case "add":
return [
...state,
{
id: action.id,
fileName: action.fileName,
file: action.file,
status: "pending",
tries: 1,
},
];
case "remove":
return state.filter((fileStatus) => fileStatus.id !== action.id);
case "update-status":
return state.map((fileStatus) => {
if (fileStatus.id === action.id) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const {id, type, ...rest} = action;
return {
...fileStatus,
...rest,
tries:
action.status === "pending"
? fileStatus.tries + 1
: fileStatus.tries,
} as FileStatus<TUploadRes, TUploadError>;
}
return fileStatus;
});
}
};
type DropZoneErrorCode = (typeof dropZoneErrorCodes)[number];
const dropZoneErrorCodes = [
"file-invalid-type",
"file-too-large",
"file-too-small",
"too-many-files",
] as const;
const getDropZoneErrorCodes = (fileRejections: FileRejection[]) => {
const errors = fileRejections.map((rejection) => {
return rejection.errors
.filter((error) =>
dropZoneErrorCodes.includes(error.code as DropZoneErrorCode),
)
.map((error) => error.code) as DropZoneErrorCode[];
});
return Array.from(new Set(errors.flat()));
};
const getRootError = (
errorCodes: DropZoneErrorCode[],
limits: {
accept?: Accept;
maxSize?: number;
minSize?: number;
maxFiles?: number;
},
) => {
const errors = errorCodes.map((error) => {
switch (error) {
case "file-invalid-type":
const acceptedTypes = Object.values(limits.accept ?? {})
.flat()
.join(", ");
return `only ${acceptedTypes} are allowed`;
case "file-too-large":
const maxMb = limits.maxSize
? (limits.maxSize / (1024 * 1024)).toFixed(2)
: "infinite?";
return `max size is ${maxMb}MB`;
case "file-too-small":
const roundedMinSize = limits.minSize
? (limits.minSize / (1024 * 1024)).toFixed(2)
: "negative?";
return `min size is ${roundedMinSize}MB`;
case "too-many-files":
return `max ${limits.maxFiles} files`;
}
});
const joinedErrors = errors.join(", ");
return joinedErrors.charAt(0).toUpperCase() + joinedErrors.slice(1);
};
type UseDropzoneProps<TUploadRes, TUploadError> = {
onDropFile: (
file: File,
) => Promise<
Exclude<DropzoneResult<TUploadRes, TUploadError>, { status: "pending" }>
>;
onRemoveFile?: (id: string) => void | Promise<void>;
onFileUploaded?: (result: TUploadRes) => void;
onFileUploadError?: (error: TUploadError) => void;
onAllUploaded?: () => void;
onRootError?: (error: string | undefined) => void;
maxRetryCount?: number;
autoRetry?: boolean;
validation?: {
accept?: Accept;
minSize?: number;
maxSize?: number;
maxFiles?: number;
};
shiftOnMaxFiles?: boolean;
} & (TUploadError extends string
? {
shapeUploadError?: (error: TUploadError) => string | void;
}
: {
shapeUploadError: (error: TUploadError) => string | void;
});
interface UseDropzoneReturn<TUploadRes, TUploadError> {
getRootProps: ReturnType<typeof rootUseDropzone>["getRootProps"];
getInputProps: ReturnType<typeof rootUseDropzone>["getInputProps"];
onRemoveFile: (id: string) => Promise<void>;
onRetry: (id: string) => Promise<void>;
canRetry: (id: string) => boolean;
fileStatuses: FileStatus<TUploadRes, TUploadError>[];
isInvalid: boolean;
isDragActive: boolean;
rootError: string | undefined;
inputId: string;
rootMessageId: string;
rootDescriptionId: string;
getFileMessageId: (id: string) => string;
}
const useDropzone = <TUploadRes, TUploadError = string>(
props: UseDropzoneProps<TUploadRes, TUploadError>,
): UseDropzoneReturn<TUploadRes, TUploadError> => {
const {
onDropFile: pOnDropFile,
onRemoveFile: pOnRemoveFile,
shapeUploadError: pShapeUploadError,
onFileUploaded: pOnFileUploaded,
onFileUploadError: pOnFileUploadError,
onAllUploaded: pOnAllUploaded,
onRootError: pOnRootError,
maxRetryCount,
autoRetry,
validation,
shiftOnMaxFiles,
} = props;
const inputId = useId();
const rootMessageId = `${inputId}-root-message`;
const rootDescriptionId = `${inputId}-description`;
const [rootError, _setRootError] = useState<string | undefined>(undefined);
const setRootError = useCallback(
(error: string | undefined) => {
_setRootError(error);
if (pOnRootError !== undefined) {
pOnRootError(error);
}
},
[pOnRootError, _setRootError],
);
const [fileStatuses, dispatch] = useReducer(fileStatusReducer, []);
const isInvalid = useMemo(() => {
return (
fileStatuses.filter((file) => file.status === "error").length > 0 ||
rootError !== undefined
);
}, [fileStatuses, rootError]);
const _uploadFile = useCallback(
async (file: File, id: string, tries = 0) => {
const result = await pOnDropFile(file);
if (result.status === "error") {
if (autoRetry === true && tries < (maxRetryCount ?? Infinity)) {
dispatch({type: "update-status", id, status: "pending"});
return _uploadFile(file, id, tries + 1);
}
dispatch({
type: "update-status",
id,
status: "error",
error:
pShapeUploadError !== undefined
? pShapeUploadError(result.error)
: result.error,
});
if (pOnFileUploadError !== undefined) {
pOnFileUploadError(result.error);
}
return;
}
if (pOnFileUploaded !== undefined) {
pOnFileUploaded(result.result);
}
dispatch({
type: "update-status",
id,
...result,
});
},
[
autoRetry,
maxRetryCount,
pOnDropFile,
pShapeUploadError,
pOnFileUploadError,
pOnFileUploaded,
],
);
const onRemoveFile = useCallback(
async (id: string) => {
await pOnRemoveFile?.(id);
dispatch({type: "remove", id});
},
[pOnRemoveFile],
);
const canRetry = useCallback(
(id: string) => {
const fileStatus = fileStatuses.find((file) => file.id === id);
return (
fileStatus?.status === "error" &&
fileStatus.tries < (maxRetryCount ?? Infinity)
);
},
[fileStatuses, maxRetryCount],
);
const onRetry = useCallback(
async (id: string) => {
if (!canRetry(id)) {
return;
}
dispatch({type: "update-status", id, status: "pending"});
const fileStatus = fileStatuses.find((file) => file.id === id);
if (!fileStatus || fileStatus.status !== "error") {
return;
}
await _uploadFile(fileStatus.file, id);
},
[canRetry, fileStatuses, _uploadFile],
);
const getFileMessageId = (id: string) => `${inputId}-${id}-message`;
const dropzone = rootUseDropzone({
accept: validation?.accept,
minSize: validation?.minSize,
maxSize: validation?.maxSize,
onDropAccepted: async (newFiles) => {
setRootError(undefined);
// useDropzone hook only checks max file count per group of uploaded files, allows going over if in multiple batches
const fileCount = fileStatuses.length;
const maxNewFiles =
validation?.maxFiles === undefined
? Infinity
: validation?.maxFiles - fileCount;
if (maxNewFiles < newFiles.length) {
if (shiftOnMaxFiles === true) {
} else {
setRootError(getRootError(["too-many-files"], validation ?? {}));
}
}
const slicedNewFiles =
shiftOnMaxFiles === true ? newFiles : newFiles.slice(0, maxNewFiles);
const onDropFilePromises = slicedNewFiles.map(async (file, index) => {
if (fileCount + 1 > maxNewFiles) {
await onRemoveFile(fileStatuses[index].id);
}
const id = crypto.randomUUID();
dispatch({type: "add", fileName: file.name, file, id});
await _uploadFile(file, id);
});
await Promise.all(onDropFilePromises);
if (pOnAllUploaded !== undefined) {
pOnAllUploaded();
}
},
onDropRejected: (fileRejections) => {
const errorMessage = getRootError(
getDropZoneErrorCodes(fileRejections),
validation ?? {}
);
setRootError(errorMessage);
},
});
return {
getRootProps: dropzone.getRootProps,
getInputProps: dropzone.getInputProps,
inputId,
rootMessageId,
rootDescriptionId,
getFileMessageId,
onRemoveFile,
onRetry,
canRetry,
fileStatuses: fileStatuses as FileStatus<TUploadRes, TUploadError>[],
isInvalid,
rootError,
isDragActive: dropzone.isDragActive,
};
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const DropZoneContext = createContext<UseDropzoneReturn<any, any>>({
getRootProps: () => ({}) as never,
getInputProps: () => ({}) as never,
onRemoveFile: async () => {
},
onRetry: async () => {
},
canRetry: () => false,
fileStatuses: [],
isInvalid: false,
isDragActive: false,
rootError: undefined,
inputId: "",
rootMessageId: "",
rootDescriptionId: "",
getFileMessageId: () => "",
});
const useDropzoneContext = <TUploadRes, TUploadError>() => {
return useContext(DropZoneContext) as UseDropzoneReturn<
TUploadRes,
TUploadError
>;
};
interface DropzoneProps<TUploadRes, TUploadError>
extends UseDropzoneReturn<TUploadRes, TUploadError> {
children: React.ReactNode;
}
const Dropzone = <TUploadRes, TUploadError>(
props: DropzoneProps<TUploadRes, TUploadError>,
) => {
const {children, ...rest} = props;
return (
<DropZoneContext.Provider value={rest}>{children}</DropZoneContext.Provider>
);
};
Dropzone.displayName = "Dropzone";
interface DropZoneAreaProps extends React.HTMLAttributes<HTMLDivElement> {
}
const DropZoneArea = forwardRef<HTMLDivElement, DropZoneAreaProps>(
({className, children, ...props}, forwardedRef) => {
const context = useDropzoneContext();
if (!context) {
throw new Error("DropzoneArea must be used within a Dropzone");
}
const {onFocus, onBlur, onDragEnter, onDragLeave, onDrop, ref} =
context.getRootProps();
return (
// A11y behavior is handled through Trigger. All of these are only relevant to drag and drop which means this should be fine?
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
ref={(instance) => {
// TODO: test if this actually works?
ref.current = instance;
if (typeof forwardedRef === "function") {
forwardedRef(instance);
} else if (forwardedRef) {
forwardedRef.current = instance;
}
}}
onFocus={onFocus}
onBlur={onBlur}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDrop={onDrop}
{...props}
aria-label="dropzone"
className={cn(
"flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
context.isDragActive && "animate-pulse bg-black/5",
context.isInvalid && "border-destructive",
className,
)}
>
{children}
</div>
);
},
);
DropZoneArea.displayName = "DropZoneArea";
export interface DropzoneDescriptionProps
extends React.HTMLAttributes<HTMLParagraphElement> {
}
const DropzoneDescription = forwardRef<
HTMLParagraphElement,
DropzoneDescriptionProps
>((props, ref) => {
const {className, ...rest} = props;
const context = useDropzoneContext();
if (!context) {
throw new Error("DropzoneDescription must be used within a Dropzone");
}
return (
<p
ref={ref}
id={context.rootDescriptionId}
{...rest}
className={cn("pb-1 text-sm text-muted-foreground", className)}
/>
);
});
DropzoneDescription.displayName = "DropzoneDescription";
interface DropzoneFileListContext<TUploadRes, TUploadError> {
onRemoveFile: () => Promise<void>;
onRetry: () => Promise<void>;
fileStatus: FileStatus<TUploadRes, TUploadError>;
canRetry: boolean;
dropzoneId: string;
messageId: string;
}
const DropzoneFileListContext = createContext<
DropzoneFileListContext<unknown, unknown>
>({
onRemoveFile: async () => {
},
onRetry: async () => {
},
fileStatus: {} as FileStatus<unknown, unknown>,
canRetry: false,
dropzoneId: "",
messageId: "",
});
const useDropzoneFileListContext = () => {
return useContext(DropzoneFileListContext);
};
interface DropZoneFileListProps
extends React.OlHTMLAttributes<HTMLOListElement> {
}
const DropzoneFileList = forwardRef<HTMLOListElement, DropZoneFileListProps>(
(props, ref) => {
const context = useDropzoneContext();
if (!context) {
throw new Error("DropzoneFileList must be used within a Dropzone");
}
return (
<ol
ref={ref}
aria-label="dropzone-file-list"
{...props}
className={cn("flex flex-col gap-4", props.className)}
>
{props.children}
</ol>
);
},
);
DropzoneFileList.displayName = "DropzoneFileList";
interface DropzoneFileListItemProps<TUploadRes, TUploadError>
extends React.LiHTMLAttributes<HTMLLIElement> {
file: FileStatus<TUploadRes, TUploadError>;
}
const DropzoneFileListItem = forwardRef<
HTMLLIElement,
DropzoneFileListItemProps<unknown, unknown>
>(({className, ...props}, ref) => {
const fileId = props.file.id;
const {
onRemoveFile: cOnRemoveFile,
onRetry: cOnRetry,
getFileMessageId: cGetFileMessageId,
canRetry: cCanRetry,
inputId: cInputId,
} = useDropzoneContext();
const onRemoveFile = useCallback(
() => cOnRemoveFile(fileId),
[fileId, cOnRemoveFile],
);
const onRetry = useCallback(() => cOnRetry(fileId), [fileId, cOnRetry]);
const messageId = cGetFileMessageId(fileId);
const isInvalid = props.file.status === "error";
const canRetry = useMemo(() => cCanRetry(fileId), [fileId, cCanRetry]);
return (
<DropzoneFileListContext.Provider
value={{
onRemoveFile,
onRetry,
fileStatus: props.file,
canRetry,
dropzoneId: cInputId,
messageId,
}}
>
<li
ref={ref}
aria-label="dropzone-file-list-item"
aria-describedby={isInvalid ? messageId : undefined}
className={cn(
"flex flex-col justify-center gap-2 rounded-md bg-muted/40 px-4 py-2",
className,
)}
>
{props.children}
</li>
</DropzoneFileListContext.Provider>
);
});
DropzoneFileListItem.displayName = "DropzoneFileListItem";
interface DropzoneFileMessageProps
extends React.HTMLAttributes<HTMLParagraphElement> {
}
const DropzoneFileMessage = forwardRef<
HTMLParagraphElement,
DropzoneFileMessageProps
>((props, ref) => {
const {children, ...rest} = props;
const context = useDropzoneFileListContext();
if (!context) {
throw new Error(
"DropzoneFileMessage must be used within a DropzoneFileListItem",
);
}
const body =
context.fileStatus.status === "error"
? String(context.fileStatus.error)
: children;
return (
<p
ref={ref}
id={context.messageId}
{...rest}
className={cn(
"h-5 text-[0.8rem] font-medium text-destructive",
rest.className,
)}
>
{body}
</p>
);
});
DropzoneFileMessage.displayName = "DropzoneFileMessage";
interface DropzoneMessageProps
extends React.HTMLAttributes<HTMLParagraphElement> {
}
const DropzoneMessage = forwardRef<HTMLParagraphElement, DropzoneMessageProps>(
(props, ref) => {
const {children, ...rest} = props;
const context = useDropzoneContext();
if (!context) {
throw new Error("DropzoneRootMessage must be used within a Dropzone");
}
const body = context.rootError ? String(context.rootError) : children;
return (
<p
ref={ref}
id={context.rootMessageId}
{...rest}
className={cn(
"h-5 text-[0.8rem] font-medium text-destructive",
rest.className,
)}
>
{body}
</p>
);
},
);
DropzoneMessage.displayName = "DropzoneMessage";
interface DropzoneRemoveFileProps extends ButtonVariantsProps {
className?: string;
children?: React.ReactNode;
}
const DropzoneRemoveFile = forwardRef<
HTMLButtonElement,
DropzoneRemoveFileProps
>(({className, ...props}, ref) => {
const context = useDropzoneFileListContext();
if (!context) {
throw new Error(
"DropzoneRemoveFile must be used within a DropzoneFileListItem",
);
}
return (
<Button
ref={ref}
onClick={context.onRemoveFile}
type="button"
size="icon"
{...props}
className={cn(
"aria-disabled:pointer-events-none aria-disabled:opacity-50",
className,
)}
>
{props.children}
<span className="sr-only">Remove file</span>
</Button>
);
});
DropzoneRemoveFile.displayName = "DropzoneRemoveFile";
interface DropzoneRetryFileProps extends ButtonVariantsProps {
className?: string;
children?: React.ReactNode;
}
const DropzoneRetryFile = forwardRef<HTMLButtonElement, DropzoneRetryFileProps>(
({className, ...props}, ref) => {
const context = useDropzoneFileListContext();
if (!context) {
throw new Error(
"DropzoneRetryFile must be used within a DropzoneFileListItem",
);
}
const canRetry = context.canRetry;
return (
<Button
ref={ref}
aria-disabled={!canRetry}
aria-label="retry"
onClick={context.onRetry}
type="button"
size="icon"
{...props}
className={cn(
"aria-disabled:pointer-events-none aria-disabled:opacity-50",
className,
)}
>
{props.children}
<span className="sr-only">Retry</span>
</Button>
);
},
);
DropzoneRetryFile.displayName = "DropzoneRetryFile";
interface DropzoneTriggerProps
extends React.LabelHTMLAttributes<HTMLLabelElement> {
}
const DropzoneTrigger = forwardRef<HTMLLabelElement, DropzoneTriggerProps>(
({className, children, ...props}, ref) => {
const context = useDropzoneContext();
if (!context) {
throw new Error("DropzoneTrigger must be used within a Dropzone");
}
const {fileStatuses, getFileMessageId} = context;
const fileMessageIds = useMemo(
() =>
fileStatuses
.filter((file) => file.status === "error")
.map((file) => getFileMessageId(file.id)),
[fileStatuses, getFileMessageId],
);
return (
<label
ref={ref}
{...props}
className={cn(
"cursor-pointer rounded-sm bg-secondary px-4 py-2 font-medium ring-offset-background transition-colors focus-within:outline-none hover:bg-secondary/80 has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-ring has-[input:focus-visible]:ring-offset-2",
className,
)}
>
{children}
<input
{...context.getInputProps({
style: {
display: undefined,
},
className: "sr-only",
tabIndex: undefined,
})}
aria-describedby={
context.isInvalid
? [context.rootMessageId, ...fileMessageIds].join(" ")
: undefined
}
aria-invalid={context.isInvalid}
/>
</label>
);
},
);
DropzoneTrigger.displayName = "DropzoneTrigger";
interface InfiniteProgressProps extends React.HTMLAttributes<HTMLDivElement> {
status: "pending" | "success" | "error";
}
const valueTextMap = {
pending: "indeterminate",
success: "100%",
error: "error",
};
const InfiniteProgress = forwardRef<HTMLDivElement, InfiniteProgressProps>(
({className, ...props}, ref) => {
const done = props.status === "success" || props.status === "error";
const error = props.status === "error";
return (
<div
ref={ref}
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuetext={valueTextMap[props.status]}
{...props}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-muted",
className,
)}
>
<div
// TODO: add proper done transition
className={cn(
"h-full w-full rounded-full bg-primary",
done ? "translate-x-0" : "animate-infinite-progress",
error && "bg-destructive",
)}
/>
</div>
);
},
);
InfiniteProgress.displayName = "InfiniteProgress";
export {
Dropzone,
DropZoneArea,
DropzoneDescription,
DropzoneFileList,
DropzoneFileListItem,
DropzoneFileMessage,
DropzoneMessage,
DropzoneRemoveFile,
DropzoneRetryFile,
DropzoneTrigger,
InfiniteProgress,
useDropzone,
};
@@ -1,31 +1,49 @@
"use client"
import {env} from "@/env.mjs";
import React, {useEffect, useState} from "react";
import {useTheme} from "next-themes";
"use client";
import {env} from "@/env.mjs";
import {useTheme} from "next-themes";
import Image from "next/image";
import {useEffect, useState} from "react";
export const AuthLogoSection = () => {
const {resolvedTheme} = useTheme();
const [mounted, setMounted] = useState(false);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
const imageTheme =
resolvedTheme === "dark"
? "/images/logo-dark.png"
: "/images/logo-light.png";
const handleLoad = () => setLoaded(true);
const style = {
transition: "opacity 0.3s ease-in-out",
opacity: loaded ? 1 : 0,
};
const imageTheme = resolvedTheme === "dark" ? "/images/logo-white.png" : "/images/logo-black.png";
return (
<div className="sm:mx-auto sm:w-full sm:max-w-md flex items-center justify-center space-x-2">
<img
className="p-12 text-black dark:text-white"
src={imageTheme}
alt="Logo"
/>
<span className="text-sm text-muted-foreground -ml-12 -mb-12">v{env.NEXT_PUBLIC_PROJECT_VERSION}</span>
<div className="sm:mx-auto sm:w-full sm:max-w-md relative flex items-center justify-center h-[160px]">
{mounted && (
<Image
src={imageTheme}
alt="Logo"
fill
priority
className="object-contain p-10"
onLoad={handleLoad}
style={style}
/>
)}
<span className="absolute bottom-10 right-5 text-sm text-muted-foreground" style={style}>
v{env.NEXT_PUBLIC_PROJECT_VERSION}
</span>
</div>
)
}
);
};
@@ -30,7 +30,7 @@ export const ForgotPasswordForm = (props: ForgotPasswordFormProps) => {
},
{
onSuccess: () => {
toast.success("Password reset successfully.");
toast.success("If an account with this email address exists, you will receive an email with instructions to reset your password.");
},
onError: (error) => {
toast.error(error.error.message);
@@ -63,6 +63,7 @@ export const LoginForm = (props: loginFormProps) => {
toast.success("Login success");
},
onError: (error) => {
console.log(error);
toast.error(error.error.message);
},
}
@@ -15,6 +15,7 @@ import {RegisterSchema, RegisterType} from "@/components/wrappers/auth/register/
import {PasswordInput} from "@/components/ui/password-input";
import {signUp} from "@/lib/auth/auth-client";
import Link from "next/link";
import {CardAuth} from "@/features/layout/card-auth";
export type registerFormProps = {
defaultValues?: RegisterType;
@@ -45,7 +46,7 @@ export const RegisterForm = (props: registerFormProps) => {
return (
<TooltipProvider>
<Card>
<CardAuth>
<CardHeader>
<div className="grid gap-2 text-center mb-2">
<h1 className="text-3xl font-bold">Create an account</h1>
@@ -144,7 +145,7 @@ export const RegisterForm = (props: registerFormProps) => {
</Form>
</CardContent>
</Card>
</CardAuth>
</TooltipProvider>
);
};
@@ -10,6 +10,7 @@ import {Card, CardContent, CardHeader} from "@/components/ui/card";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {authClient} from "@/lib/auth/auth-client";
import {toast} from "sonner";
import {CardAuth} from "@/features/layout/card-auth";
type ResetPasswordFormProps = {
token: string;
@@ -43,7 +44,7 @@ export const ResetPasswordForm = ({token}: ResetPasswordFormProps) => {
});
return (
<Card>
<CardAuth>
<CardHeader>
<div className="grid gap-2 text-center mb-2">
<h1 className="text-3xl font-bold">Reset Password</h1>
@@ -89,6 +90,6 @@ export const ResetPasswordForm = ({token}: ResetPasswordFormProps) => {
</ButtonWithLoading>
</Form>
</CardContent>
</Card>
</CardAuth>
);
};
@@ -5,9 +5,12 @@ import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth/auth-client";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import {SUPPORTED_PROVIDERS} from "../../../../portabase.config";
import {AuthProviderConfig} from "../../../../portabase.config";
import {Icon} from "@iconify/react";
export function SocialAuthButtons({ providers }: { providers: AuthProviderConfig[] }) {
const socialProviders = providers.filter(p => p.isActive && !p.isManual);
export function SocialAuthButtons() {
const [isLoading, setIsLoading] = useState<string | null>(null);
const handleSocialSignIn = async (providerId: string) => {
@@ -30,16 +33,15 @@ export function SocialAuthButtons() {
}
};
const socialProviders = SUPPORTED_PROVIDERS.filter((p) => !p.isManual);
if (socialProviders.length === 0) return null;
return (
<div className="flex flex-col gap-2 w-full">
{socialProviders.map((provider) => (
<Button key={provider.id} variant="outline" className="w-full gap-2" onClick={() => handleSocialSignIn(provider.id)} disabled={!!isLoading}>
{isLoading === provider.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <provider.icon className="h-4 w-4" />}
{isLoading === provider.id ? <Loader2 className="h-4 w-4 animate-spin" /> :
<Icon icon={provider.icon} className="h-4 w-4"/>
}
<span>{PROVIDERS_TEXT[provider.id].title}</span>
</Button>
))}
+11 -4
View File
@@ -43,13 +43,17 @@ export function ComboBox<T = string>(props: ComboBoxProps<T>) {
<PopoverTrigger asChild>
{sideBar ? (
<SidebarMenuButton>
<label className="max-w-[170px] truncate">
{value ? choices.find((c) => c.value === value)?.label : "Select choice..."}
<ChevronDown className="ml-auto"/>
</label>
<ChevronDown className="ml-auto"/>
</SidebarMenuButton>
) : (
<Button variant="outline" role="combobox" aria-expanded={open}
className="w-full justify-between">
{value ? choices.find((c) => c.value === value)?.label : "Select choice..."}
<label className="max-w-[170px] truncate">
{value ? choices.find((c) => c.value === value)?.label : "Select choice..."}
</label>
<ChevronDown className="opacity-50"/>
</Button>
)}
@@ -59,7 +63,8 @@ export function ComboBox<T = string>(props: ComboBoxProps<T>) {
className="p-0"
align="start"
sideOffset={4}
style={{ width: 'var(--radix-popover-trigger-width)' }}
style={{width: 'var(--radix-popover-trigger-width)'}}
>
<Command>
{searchField && <CommandInput placeholder="Search choice..." className="h-9"/>}
@@ -79,7 +84,9 @@ export function ComboBox<T = string>(props: ComboBoxProps<T>) {
}
}}
>
{choice.label}
<label className="max-w-[170px] truncate">
{choice.label}
</label>
<Check
className={cn("ml-auto", value === choice.value ? "opacity-100" : "opacity-0")}/>
</CommandItem>
@@ -0,0 +1,137 @@
"use client";
import {
CircleCheck,
CircleX,
CloudUploadIcon,
Loader2,
Trash2Icon,
} from "lucide-react";
import {
DropZoneArea,
DropzoneDescription,
DropzoneFileList,
DropzoneFileListItem,
DropzoneMessage,
DropzoneRemoveFile,
DropzoneTrigger,
useDropzone,
Dropzone,
} from "@/components/ui/dropzone";
type DropZoneFileProps = {
onFileDropAction?: (file: File) => Promise<void> | void;
onFileRemoveAction?: (file: File) => void;
accept?: Record<string, string[]>;
maxSize?: number;
maxFiles?: number;
description?: string;
fileKind?: string;
dragMessage?: string;
fileList?: boolean;
};
export const DropZoneFile = ({
onFileDropAction,
onFileRemoveAction,
accept = {"application/pdf": [".pdf"]},
maxSize = 50 * 1024 * 1024,
maxFiles = 1,
description = "Please select a file",
fileKind = "Upload files (.pdf)",
dragMessage = "Click here or drag & drop to upload",
fileList = true
}: DropZoneFileProps) => {
const dropzone = useDropzone({
onDropFile: async (file: File) => {
onFileDropAction?.(file);
return {
status: "success",
result: URL.createObjectURL(file),
};
},
validation: {
accept,
maxSize,
maxFiles,
},
});
const handleRemove = (file: any) => {
dropzone.onRemoveFile(file.id);
onFileRemoveAction?.(file.file);
};
return (
<div className="not-prose flex flex-col gap-4 h-full">
<Dropzone {...dropzone}>
<div className="h-full">
{dropzone.fileStatuses.length < maxFiles && (
<div className="flex flex-col h-full">
<div className="flex justify-between">
<DropzoneDescription>{description}</DropzoneDescription>
<DropzoneMessage/>
</div>
<DropZoneArea className="flex-1 px-0 py-0 mt-2">
<DropzoneTrigger
className="flex h-full w-full flex-col items-center justify-center gap-4 bg-transparent p-10 text-center text-sm">
<CloudUploadIcon className="size-8"/>
<div>
<p className="font-semibold">{fileKind}</p>
<p className="text-sm text-muted-foreground">
{dragMessage}
</p>
</div>
</DropzoneTrigger>
</DropZoneArea>
</div>
)}
</div>
{fileList && (
<DropzoneFileList className="grid gap-3 p-0">
{dropzone.fileStatuses.map((file) => (
<DropzoneFileListItem
key={file.id}
file={file}
className="overflow-hidden rounded-md bg-secondary p-0 shadow-sm"
>
<div className="flex items-center justify-between p-2 pl-4">
<div className="flex overflow-hidden items-center gap-3">
<div>
{file.status === "pending" ? (
<Loader2 className="animate-spin" size={16}/>
) : file.status === "error" ? (
<CircleX className="text-red-600" size={16}/>
) : (
<CircleCheck className="text-green-700" size={16}/>
)}
</div>
<div className="min-w-0">
<div className="truncate text-sm flex">
<span>{file.fileName}</span>
</div>
<p className="text-xs text-muted-foreground">
{(file.file.size / (1024 * 1024)).toFixed(2)} MB
</p>
</div>
</div>
<DropzoneRemoveFile
variant="ghost"
className="shrink-0 hover:outline"
// @ts-ignore
onClick={() => handleRemove(file)}
>
<Trash2Icon className="size-4"/>
</DropzoneRemoveFile>
</div>
</DropzoneFileListItem>
))}
</DropzoneFileList>
)}
</Dropzone>
</div>
);
};
@@ -9,7 +9,7 @@ export const GitHubStarsButtonCustom = () => {
const [stars, setStars] = React.useState(0);
const [isLoading, setIsLoading] = React.useState(true);
const username = "Soluce-Technologies"
const username = "Portabase"
const repo = "portabase"
@@ -0,0 +1,35 @@
"use client";
import { useState } from "react";
import { Plus } from "lucide-react";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Organization } from "@/db/schema/03_organization";
import {AdminUserForm} from "@/components/wrappers/dashboard/admin/users/admin-user-form";
type AdminUserAddModalProps = {
organizations: Organization[];
};
export const AdminUserAddModal = ({ organizations }: AdminUserAddModalProps) => {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<Plus /> Create a user
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create a new user</DialogTitle>
<DialogDescription>To create a new user please provide following informations</DialogDescription>
<AdminUserForm organizations={organizations} onSuccess={() => setOpen(false)} />
</DialogHeader>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,65 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { User } from "@/db/schema/02_user";
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { requestPasswordReset } from "@/lib/auth/auth-client";
import { toast } from "sonner";
type AdminUserChangePasswordProps = {
open: boolean;
user: User;
onOpenChange: (open: boolean) => void;
};
export const AdminUserChangePassword = ({ user, open, onOpenChange }: AdminUserChangePasswordProps) => {
const mutation = useMutation({
mutationFn: async () => {
await requestPasswordReset(
{
email: user.email,
},
{
onSuccess: () => {
toast.success("Reset password request successfully sent!");
onOpenChange(false);
},
onError: (error) => {
toast.error(error.error.message);
},
}
);
},
onError: (error) => {
toast.error(error.message);
},
});
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Change {user.name}'s password</AlertDialogTitle>
<AlertDialogDescription>This action will send an email to the user to reset their password.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<ButtonWithLoading onClick={async () => await mutation.mutateAsync()} isPending={mutation.isPending}>
Validate
</ButtonWithLoading>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
@@ -0,0 +1,91 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { authClient } from "@/lib/auth/auth-client";
import { User } from "@/db/schema/02_user";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
type AdminUserChangeRoleModalProps = {
open: boolean;
user: User;
onOpenChange: (open: boolean) => void;
};
export const AdminUserChangeRoleModal = (props: AdminUserChangeRoleModalProps) => {
const { user, open, onOpenChange } = props;
const router = useRouter();
const [role, setRole] = useState<string | null>(user.role);
const mutation = useMutation({
mutationFn: async () => {
await authClient.admin.setRole(
{
userId: user.id,
// @ts-ignore
role: role,
},
{
onSuccess: async (response) => {
toast.success("User role changed successfully.");
onOpenChange(false);
router.refresh();
},
onError: async (error) => {
console.error("AdminUserChangeRoleModal - setRole", error);
toast.error("An error occurred while updating user roles.");
onOpenChange(false);
},
}
);
},
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Change the user's role</DialogTitle>
<DialogDescription>Modify this user's role within your organization.</DialogDescription>
</DialogHeader>
<Select defaultValue={user.role ?? ""} onValueChange={setRole}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Sélectionnez un rôle" />
</SelectTrigger>
<SelectContent>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
<DialogFooter>
<div className="flex gap-4 justify-end">
<ButtonWithLoading
variant="outline"
onClick={() => {
onOpenChange(false);
}}
>
Cancel
</ButtonWithLoading>
<ButtonWithLoading
isPending={mutation.isPending}
onClick={async () => {
await mutation.mutateAsync();
}}
>
Validate
</ButtonWithLoading>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,75 @@
"use client";
import {useMutation} from "@tanstack/react-query";
import {User} from "@/db/schema/02_user";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {useRouter} from "next/navigation";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {authClient} from "@/lib/auth/auth-client";
import {toast} from "sonner";
import {setSuperAdminOwnerOfOrganizationsOwnedByUser} from "@/components/wrappers/dashboard/admin/users/user.action";
type AdminDeleteUserModalProps = {
open: boolean;
user: User;
onOpenChange: (open: boolean) => void;
};
export const AdminDeleteUserModal = ({user, open, onOpenChange}: AdminDeleteUserModalProps) => {
const router = useRouter();
const mutation = useMutation({
mutationFn: async () => {
const res = await setSuperAdminOwnerOfOrganizationsOwnedByUser({userId: user.id});
const result = res?.data;
if (result?.success) {
await authClient.admin.removeUser(
{
userId: user.id,
},
{
onSuccess: async (response) => {
toast.success(`User ${user.name} successfully deleted`);
onOpenChange(false);
router.refresh();
},
onError: async (error) => {
toast.error("An error has occurred while deleting user");
onOpenChange(false);
},
}
);
} else {
toast.error("An error has occurred while deleting user");
}
},
onError: (error) => {
toast.error(error.message);
},
});
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure you want to delete {user.name} ?</AlertDialogTitle>
<AlertDialogDescription>This action is irreversible: it will lead to the deletion of the user's
data.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<ButtonWithLoading
onClick={async () => await mutation.mutateAsync()}>Confirm</ButtonWithLoading>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
@@ -0,0 +1,100 @@
"use client";
import { useRouter } from "next/navigation";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
import {Button} from "@/components/ui/button";
import {UserEditSchema, UserEditType, UserSchema} from "@/components/wrappers/dashboard/admin/users/user.schema";
import {updateUserAction} from "@/components/wrappers/dashboard/admin/users/user.action";
type AdminUserEditFormProps = {
onSuccess?: () => void;
defaultValues: {
id: string;
} & UserEditType;
};
export const AdminUserEditForm = ({ onSuccess, defaultValues }: AdminUserEditFormProps) => {
const router = useRouter();
const form = useZodForm({
schema: defaultValues ? UserEditSchema : UserSchema,
defaultValues: {
name: defaultValues.name,
email: defaultValues.email,
},
});
const onCancel = () => {
form.reset();
onSuccess?.();
};
const mutation = useMutation({
mutationFn: async (data: UserEditType) => {
const result = await updateUserAction({
...data,
id: defaultValues?.id || "",
});
console.log(result)
const inner = result?.data;
if (inner?.success) {
toast.success("User Successfully updated");
onSuccess?.();
router.refresh();
} else {
toast.error("An error occurred");
onSuccess?.();
}
},
});
return (
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Enter a name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input disabled placeholder="Fill user email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-4 justify-end">
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
<ButtonWithLoading type="submit" isPending={mutation.isPending}>Validate</ButtonWithLoading>
</div>
</Form>
);
};
@@ -0,0 +1,43 @@
"use client";
import {User} from "@/db/schema/02_user";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from "@/components/ui/dialog";
import {AdminUserEditForm} from "./admin-user-edit-form";
type AdminUserEditPasswordProps = {
open: boolean;
user: User;
onOpenChange: (open: boolean) => void;
};
export const AdminUserEdit = ({user, open, onOpenChange}: AdminUserEditPasswordProps) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit ${user.name}'s profile</DialogTitle>
<DialogDescription>Update following information</DialogDescription>
</DialogHeader>
<DialogContent
onOpenAutoFocus={(e) => e.preventDefault()}
>
<AdminUserEditForm
defaultValues={{
name: user.name,
email: user.email,
id: user.id,
}}
onSuccess={() => onOpenChange(false)}
/>
</DialogContent>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,95 @@
"use client";
import {useRouter} from "next/navigation";
import {useMutation} from "@tanstack/react-query";
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
import {Organization} from "@/db/schema/03_organization";
import {Input} from "@/components/ui/input";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {Button} from "@/components/ui/button";
import {UserSchema, UserType} from "@/components/wrappers/dashboard/admin/users/user.schema";
import {toast} from "sonner";
import {createUserAction} from "@/components/wrappers/dashboard/admin/users/user.action";
type AdminUserFormProps = {
onSuccess?: () => void;
organizations: Organization[];
};
export const AdminUserForm = ({onSuccess, organizations}: AdminUserFormProps) => {
const router = useRouter();
const form = useZodForm({
schema: UserSchema,
});
const onCancel = () => {
form.reset();
onSuccess?.();
};
const mutation = useMutation({
mutationFn: async (data: UserType) => {
const result = await createUserAction(data);
const inner = result?.data;
if (inner?.success) {
toast.success("User Successfully created");
onSuccess?.();
router.refresh();
} else {
toast.error("An error occurred");
onSuccess?.();
}
},
});
return (
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="name"
render={({field}) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Enter a name" {...field} value={field.value ?? ""}/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({field}) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="Fill user email" {...field} value={field.value ?? ""}/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<div className="flex gap-4 justify-end">
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
<ButtonWithLoading type="submit" isPending={mutation.isPending}>
Validate
</ButtonWithLoading>
</div>
</Form>
);
};
@@ -0,0 +1,12 @@
"use client";
import { DataTable } from "@/components/wrappers/common/table/data-table";
import {usersListColumns} from "@/components/wrappers/dashboard/admin/users/table-colums";
import {User} from "@/db/schema/02_user";
type AdminUserListProps = {
users: User[];
};
export const AdminUserList = ({ users }: AdminUserListProps) => {
return <DataTable columns={usersListColumns()} data={users} enablePagination={true} enableSelect={false} />;
};
@@ -0,0 +1,91 @@
import {ColumnDef} from "@tanstack/react-table";
import {User} from "@/db/schema/02_user";
import {Badge} from "@/components/ui/badge";
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
import {formatLocalizedDate, timeAgo} from "@/utils/date-formatting";
import {Tooltip, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
import {TooltipContent} from "@radix-ui/react-tooltip";
import {Info} from "lucide-react";
import {Table, TableBody, TableCell, TableRow} from "@/components/ui/table";
import {UserActionsCell} from "@/components/wrappers/dashboard/admin/users/user-actions-cell";
export function usersListColumns(): ColumnDef<User>[] {
return [
{
accessorKey: "name",
header: "Profile",
cell: ({row}) => {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="flex flex-row items-center gap-x-2">
<Avatar>
<AvatarImage src={row.original.image ?? ""} alt={row.original.name}/>
<AvatarFallback>
{row.original.name
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
<div className="flex flex-row items-center gap-x-2">
<div className="font-medium">{row.original.name}</div>
<Info size={16} aria-hidden="true" className="text-muted-foreground"/>
</div>
</div>
</TooltipTrigger>
<TooltipContent className="bg-background shadow-lg border border-border z-20 rounded-md"
side="bottom">
<Table>
<TableBody>
<TableRow>
<TableCell>Joined</TableCell>
<TableCell
className="text-right text-muted-foreground">{formatLocalizedDate(row.original.createdAt)}</TableCell>
</TableRow>
{/*{row.original.lastConnectedAt && (*/}
{/* <TableRow>*/}
{/* <TableCell>Last connected</TableCell>*/}
{/* <TableCell className="text-right text-muted-foreground">*/}
{/* {formatLocalizedDate(row.original.lastConnectedAt)} ({timeAgo(row.original.lastConnectedAt, locale)})*/}
{/* </TableCell>*/}
{/* </TableRow>*/}
{/*)}*/}
{row.original.lastChangedPasswordAt && (
<TableRow>
<TableCell>Last password change</TableCell>
<TableCell className="text-right text-muted-foreground">
{formatLocalizedDate(row.original.lastChangedPasswordAt)} ({timeAgo(row.original.lastChangedPasswordAt)})
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
},
},
{
accessorKey: "email",
header: "Email",
},
{
accessorKey: "role",
header: "Role",
cell: ({row}) => {
const role = row.original.role!;
return <Badge>{role}</Badge>;
},
},
{
id: "actions",
header: "Actions",
cell: ({row}) => <UserActionsCell user={row.original}/>,
},
];
}
@@ -0,0 +1,78 @@
"use client";
import {useState} from "react";
import {Button} from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu";
import {MoreHorizontal, Settings, Trash2, RotateCcwKey, UserCog} from "lucide-react";
import {authClient} from "@/lib/auth/auth-client";
import {cn} from "@/lib/utils";
import {User} from "@/db/schema/02_user";
import {AdminUserChangePassword} from "./admin-user-change-password-modal";
import {AdminUserEdit} from "./admin-user-edit-modal";
import {AdminUserChangeRoleModal} from "@/components/wrappers/dashboard/admin/users/admin-user-change-role-modal";
import {AdminDeleteUserModal} from "@/components/wrappers/dashboard/admin/users/admin-user-delete-modal";
interface UserActionsCellProps {
user: User;
}
export function UserActionsCell({user}: UserActionsCellProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isModalChangePasswordOpen, setIsModalChangePasswordOpen] = useState(false);
const [isModalEditUserOpen, setIsModalEditUserOpen] = useState(false);
const [isModalDeleteOpen, setIsModalDeleteOpen] = useState(false);
const {data: session, isPending, error} = authClient.useSession();
if (isPending || error) return null;
const isCurrentUser = session?.user?.id === user.id;
const isSuperAdmin = session?.user?.role === "superadmin";
if (isCurrentUser || user.role === "superadmin") return null;
return (
<>
<AdminUserChangeRoleModal user={user} open={isModalOpen} onOpenChange={setIsModalOpen}/>
<AdminDeleteUserModal user={user} open={isModalDeleteOpen} onOpenChange={setIsModalDeleteOpen}/>
<AdminUserChangePassword user={user} open={isModalChangePasswordOpen}
onOpenChange={setIsModalChangePasswordOpen}/>
<AdminUserEdit user={user} open={isModalEditUserOpen} onOpenChange={setIsModalEditUserOpen}/>
<div className={cn("flex items-center space-x-2")}>
<Button variant="outline" size="icon" onClick={() => setIsModalChangePasswordOpen(true)}>
<RotateCcwKey className="w-4 h-4"/>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="w-4 h-4"/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setIsModalOpen(true)}>
<Settings className="w-4 h-4 mr-2"/>
Role
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setIsModalEditUserOpen(true)}>
<UserCog className="w-4 h-4 mr-2"/>
Edit User
</DropdownMenuItem>
{isSuperAdmin && (
<>
<DropdownMenuSeparator/>
<DropdownMenuItem onSelect={() => setIsModalDeleteOpen(true)} className="text-red-600">
<Trash2 className="w-4 h-4 mr-2"/>
Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
);
}
@@ -0,0 +1,191 @@
"use server";
import * as drizzleDb from "@/db";
import {ServerActionResult} from "@/types/action-type";
import {render} from "@react-email/render";
import {UserSchema} from "@/components/wrappers/dashboard/admin/users/user.schema";
import {extractNameFromEmail} from "@/utils/name-from-email";
import {generateValidPassword} from "@/utils/password";
import {auth} from "@/lib/auth/auth";
import {z} from "zod";
import {Organization} from "@/db/schema/03_organization";
import {db} from "@/db";
import {and, eq} from "drizzle-orm";
import {zEmail, zString} from "@/lib/zod";
import {withUpdatedAt} from "@/db/utils";
import {userAction} from "@/lib/safe-actions/actions";
import {
addMemberOrganizationAction
} from "@/components/wrappers/dashboard/admin/organizations/organization/details/add-member.action";
import {sendEmail} from "@/lib/email/email-helper";
import EmailCreateUser from "@/components/emails/email-create-user";
import {SignUpUser} from "@/types/auth";
import {createUserDb} from "@/db/services/user";
import {User} from "@/db/schema/02_user";
export const createUserAction = userAction.schema(UserSchema).action(async ({parsedInput}): Promise<ServerActionResult<User>> => {
try {
const password = generateValidPassword();
const userData: SignUpUser = {
name: parsedInput.name || extractNameFromEmail(parsedInput.email),
email: parsedInput.email,
password: password,
theme: "dark",
role: "user",
};
const newUser = await createUserDb(userData);
if (newUser) {
await sendEmail({
to: parsedInput.email,
subject: "Your account is created",
html: await render(EmailCreateUser({
password: password,
email: parsedInput.email,
})),
});
const defaultOrganization = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.slug, "default"),
});
if (defaultOrganization) {
await auth.api.addMember({
body: {
userId: newUser.id,
organizationId: defaultOrganization.id,
role: "admin",
},
});
}
return {
success: true,
value: newUser,
actionSuccess: {
message: "user_created",
},
};
}
return {
success: false,
actionError: {
message: "user_created",
cause: "Unknown error",
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "user_created",
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
export const updateUserAction = userAction
.schema(
z.object({
id: zString(),
name: zString().optional(),
email: zEmail(),
})
)
.action(async ({parsedInput, ctx}): Promise<ServerActionResult<{}>> => {
try {
const [updatedUser] = await db.update(drizzleDb.schemas.user).set(withUpdatedAt({
name: parsedInput.name ? parsedInput.name : extractNameFromEmail(parsedInput.email),
email: parsedInput.email,
emailVerified: false
})).where(eq(drizzleDb.schemas.user.id, parsedInput.id)).returning();
if (updatedUser) {
return {
success: true,
actionSuccess: {
message: "user_updated",
},
};
}
return {
success: false,
actionError: {
message: "user_updated",
cause: "Unknown error",
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "user_updated",
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
export const setSuperAdminOwnerOfOrganizationsOwnedByUser = userAction
.schema(
z.object({
userId: z.string(),
})
)
.action(async ({parsedInput}): Promise<ServerActionResult<Organization[]>> => {
try {
const organizationsWhereUserIsMemberAndOwner = await db.query.member.findMany({
where: and(eq(drizzleDb.schemas.member.role, "owner"), eq(drizzleDb.schemas.member.userId, parsedInput.userId)),
with: {
organization: true,
},
});
const superAdminUser = await db.query.user.findFirst();
if (!superAdminUser) {
return {
success: false,
actionError: {
message: "set_super_admin_owner_of_organizations_owned_by_user",
cause: "Unknown error",
},
};
}
for (let {organization} of organizationsWhereUserIsMemberAndOwner) {
await addMemberOrganizationAction({
userId: superAdminUser.id,
organizationId: organization.id,
role: "owner",
});
}
const organizations = organizationsWhereUserIsMemberAndOwner.map(
(organizationWhereUserIsMemberAndOwner) => organizationWhereUserIsMemberAndOwner.organization
);
return {
success: true,
value: organizations as unknown as Organization[],
actionSuccess: {
message: "set_super_admin_owner_of_organizations_owned_by_user",
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "error_set_super_admin_owner_of_organizations_owned_by_user",
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
@@ -0,0 +1,16 @@
import { z } from "zod";
import { zEmail, zString } from "@/lib/zod";
export const UserSchema = z.object({
email: zEmail(),
name: zString(),
});
export type UserType = z.infer<typeof UserSchema>;
export const UserEditSchema = z.object({
email: zEmail(),
name: zString(),
});
export type UserEditType = z.infer<typeof UserEditSchema>;
@@ -0,0 +1,79 @@
"use client"
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {Server} from "lucide-react";
import {formatDateLastContact} from "@/utils/date-formatting";
import {AgentCardKey} from "@/components/wrappers/dashboard/agent/agent-card-key/agent-card-key";
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
import {DatabaseCard} from "@/components/wrappers/dashboard/projects/project-card/project-database-card";
import {AgentWithDatabases} from "@/db/schema/08_agent";
import {eventUpdate} from "@/types/events";
import {useAutoRefresh} from "@/hooks/use-auto-refresh";
type AgentContentPageProps = {
edgeKey: string;
agent: AgentWithDatabases
}
export const AgentContentPage = ({edgeKey, agent}: AgentContentPageProps) => {
useAutoRefresh({
poll: {
enabled: true,
intervalMs: 5000,
},
sse: {
enabled: true,
url: "/api/events",
eventName: "modification",
shouldRefresh: (data) => {
const update = data as eventUpdate;
return Boolean(update?.update);
},
},
});
return (
<>
<div className="flex flex-col sm:flex-row sm:justify-between gap-6 ">
<Card className="w-full sm:w-auto flex-1">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Databases</CardTitle>
<Server className="h-4 w-4 text-muted-foreground"/>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{agent.databases.length}</div>
<p className="text-xs text-muted-foreground">Databases linked to this agent</p>
</CardContent>
</Card>
<Card className="w-full sm:w-auto flex-1">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Last contact</CardTitle>
<Server className="h-4 w-4 text-muted-foreground"/>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatDateLastContact(agent.lastContact)}</div>
<p className="text-xs text-muted-foreground">Last contact with agent</p>
</CardContent>
</Card>
</div>
<Card className="w-full sm:w-auto flex-1 ">
<CardHeader className="font-bold text-xl">
Edge Key
</CardHeader>
<CardContent>
<AgentCardKey
edgeKey={edgeKey}
/>
</CardContent>
</Card>
<CardsWithPagination cardsPerPage={4} numberOfColumns={2} data={agent.databases}
cardItem={DatabaseCard}/>
</>
)
}
@@ -1,6 +1,7 @@
import {currentUser} from "@/lib/auth/current-user";
import {getAccounts, getSession, getSessions} from "@/lib/auth/auth";
import {LoggedInButtonClient} from "./logged-in-button";
import {SUPPORTED_PROVIDERS} from "../../../../../../portabase.config";
export const LoggedInButton = async () => {
const user = await currentUser();
@@ -18,6 +19,7 @@ export const LoggedInButton = async () => {
// @ts-ignore
currentSession={currentSession.session}
accounts={accounts}
providers={SUPPORTED_PROVIDERS.filter((p) => p.isActive)}
/>
);
};
@@ -1,62 +1,27 @@
// import { ChevronUp } from "lucide-react";
//
// import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
// import { currentUser } from "@/lib/auth/current-user";
// import { SidebarMenuButton } from "@/components/ui/sidebar";
// import { getAccounts, getSession, getSessions } from "@/lib/auth/auth";
// import {LoggedInDropdown} from "@/components/wrappers/dashboard/common/logged-in/logged-in-dropdown";
//
// export const LoggedInButton = async () => {
// const user = await currentUser();
// const sessions = await getSessions();
// const currentSession = await getSession();
// const accounts = await getAccounts();
//
// if (!user) return null;
//
//
// return (
// <>
// <LoggedInDropdown
// // @ts-ignore
// user={user}
// // @ts-ignore
// sessions={sessions}
// // @ts-ignore
// currentSession={currentSession.session}
// // @ts-ignore
// accounts={accounts}
// >
// <SidebarMenuButton>
//
// <Avatar className="size-6">
// <AvatarFallback>{user?.name[0].toUpperCase()}</AvatarFallback>
// {user?.image ? <AvatarImage src={user?.image} alt={`${user.name ?? "-"}'s profile picture`} /> : null}
// </Avatar>
//
// <span className="first-letter:capitalize">{user?.name}</span>
// <ChevronUp className="ml-auto" />
// </SidebarMenuButton>
// </LoggedInDropdown>
// </>
// );
// };
"use client";
import {ChevronUp} from "lucide-react";
import {ChevronsUpDown, ChevronUp} from "lucide-react";
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
import {SidebarMenuButton} from "@/components/ui/sidebar";
import {LoggedInDropdown} from "./logged-in-dropdown";
import {Account, Session, User} from "better-auth";
import {AuthProviderConfig} from "../../../../../../portabase.config";
type LoggedInButtonClientProps = {
user: User;
sessions: Session[];
currentSession: Session;
accounts: Account[];
user: User,
sessions: Session[],
currentSession: Session,
accounts: Account[],
providers: AuthProviderConfig[]
}
export const LoggedInButtonClient = ({user, sessions, currentSession, accounts}: LoggedInButtonClientProps) => {
export const LoggedInButtonClient = ({
user,
sessions,
currentSession,
accounts,
providers
}: LoggedInButtonClientProps) => {
return (
<LoggedInDropdown
@@ -68,15 +33,25 @@ export const LoggedInButtonClient = ({user, sessions, currentSession, accounts}:
currentSession={currentSession}
// @ts-ignore
accounts={accounts}
providers={providers}
>
<SidebarMenuButton type="button">
<Avatar className="size-6">
<AvatarFallback>{user.name[0].toUpperCase()}</AvatarFallback>
{user.image && <AvatarImage src={user.image}/>}
</Avatar>
<span className="first-letter:capitalize">{user.name}</span>
<ChevronUp className="ml-auto"/>
<SidebarMenuButton type="button" className="h-auto justify-between py-2" >
<div className="flex items-center gap-2">
<Avatar className="size-6">
<AvatarFallback>{user.name[0].toUpperCase()}</AvatarFallback>
{user.image && <AvatarImage src={user.image}/>}
</Avatar>
<div className="flex flex-col items-start">
<span className="text-sm font-medium first-letter:capitalize max-w-[170px] truncate">{user.name}</span>
<span
className="text-xs text-muted-foreground max-w-[170px] truncate"
title={user.email}
>
{user.email}
</span>
</div>
</div>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50"/>
</SidebarMenuButton>
</LoggedInDropdown>
);
@@ -7,6 +7,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import { signOut } from "@/lib/auth/auth-client";
import {ProfileModal} from "@/components/wrappers/dashboard/common/profile/profile-modal";
import {Account, Session, User} from "@/db/schema/02_user";
import {AuthProviderConfig} from "../../../../../../portabase.config";
export type LoggedInDropdownProps = PropsWithChildren<{
user: User;
@@ -14,9 +15,11 @@ export type LoggedInDropdownProps = PropsWithChildren<{
currentSession: Session;
accounts: Account[];
children: ReactNode;
providers: AuthProviderConfig[]
}>;
export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, children }: LoggedInDropdownProps) => {
export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, children, providers }: LoggedInDropdownProps) => {
const router = useRouter();
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -29,6 +32,7 @@ export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, chi
accounts={accounts}
open={isModalOpen}
onOpenChange={setIsModalOpen}
providers={providers}
/>
<DropdownMenu>
@@ -10,6 +10,7 @@ import {ProfileAccount} from "@/components/wrappers/dashboard/profile/profile-ac
import {ProfileAppearance} from "@/components/wrappers/dashboard/profile/profile-apperance";
import {ProfileSecurity} from "@/components/wrappers/dashboard/profile/profile-security";
import {ProfileGeneral} from "@/components/wrappers/dashboard/profile/profile-general";
import {AuthProviderConfig} from "../../../../../../portabase.config";
type ProfileModalProps = {
open: boolean;
@@ -18,9 +19,11 @@ type ProfileModalProps = {
currentSession: Session;
accounts: Account[];
onOpenChange: (open: boolean) => void;
providers: AuthProviderConfig[]
};
export const ProfileModal = ({ user, sessions, currentSession, accounts, open, onOpenChange }: ProfileModalProps) => {
export const ProfileModal = ({ user, sessions, currentSession, accounts, open, onOpenChange, providers }: ProfileModalProps) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[95vw] h-[90vh] max-w-md lg:max-w-[1000px] lg:h-[800px] p-0 overflow-hidden flex flex-col outline-none gap-0 rounded-xl bg-background">
@@ -46,7 +49,7 @@ export const ProfileModal = ({ user, sessions, currentSession, accounts, open, o
</TabsContent>
<TabsContent value="providers" className="mt-0 h-full p-6 lg:p-10 outline-none focus-visible:ring-0">
<ProfileProviders accounts={accounts} />
<ProfileProviders accounts={accounts} providers={providers} />
</TabsContent>
<TabsContent value="account" className="mt-0 h-full p-6 lg:p-10 outline-none focus-visible:ring-0">
@@ -15,7 +15,7 @@ import {LoggedInButton} from "@/components/wrappers/dashboard/common/logged-in/l
export function AppSidebar() {
const projectName = env.PROJECT_NAME;
return (
<Sidebar collapsible="icon">
<Sidebar collapsible="icon" >
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
@@ -34,7 +34,6 @@ export function AppSidebar() {
<SidebarMenu className="mb-2">
<SidebarMenuItem className="p-2">
<LoggedInButton/>
</SidebarMenuItem>
</SidebarMenu>
<SideBarFooterCredit/>
@@ -1,48 +1,64 @@
"use client"
"use client";
import { useSidebar } from "@/components/ui/sidebar";
import Link from "next/link";
import { useTheme } from "next-themes";
import Image from "next/image";
import { useEffect, useState } from "react";
import {Skeleton} from "@/components/ui/skeleton";
export const SidebarLogo = ({projectName}: {
projectName: string;
}) => {
export const SidebarLogo = ({ projectName }: { projectName: string }) => {
const { state, isMobile } = useSidebar();
const { resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
if (!mounted) return(
<div className="m-4 w-[190px] h-[45px]">
<Skeleton className="w-full h-full bg-transparent" />
</div>
);
const imageTheme = resolvedTheme === "dark" ? "/images/logo-white.png" : "/images/logo-black.png";
const imageTheme =
resolvedTheme === "dark" ? "/images/logo-dark.png" : "/images/logo-light.png";
const handleLoad = () => setLoaded(true);
const style = {
transition: "opacity 0.3s ease-in-out",
opacity: loaded ? 1 : 0,
};
return (
<div className="ml-1 flex items-center justify-center">
<Link href={"/dashboard/home"}>
{state === 'collapsed' && !isMobile ? (
<Link href="/dashboard/home">
{state === "collapsed" && !isMobile ? (
<Image
loading="eager"
src={"/images/logo.png"}
src="/images/logo.png"
alt={`Logo ${projectName}`}
className="h-10 w-10 object-contain"
height={10}
width={10}
width={40}
height={40}
loading="eager"
priority
style={style}
onLoad={handleLoad}
/>
) : (
<div className="m-4">
<div className="m-4 w-[190px] h-[45px] ">
<Image
loading="eager"
src={imageTheme}
alt={`Logo ${projectName}`}
className="object-contain"
width={190}
height={90}
loading="eager"
priority
style={style}
onLoad={handleLoad}
/>
</div>
)}
@@ -0,0 +1,46 @@
"use client"
import {useState} from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger
} from "@/components/ui/dialog";
import {Button} from "@/components/ui/button";
import {DatabaseWith} from "@/db/schema/07_database";
import {Separator} from "@/components/ui/separator";
import {Import} from "lucide-react";
import {UploadBackupZone} from "@/components/wrappers/dashboard/database/import/upload-backup-zone";
type ImportModalProps = {
database: DatabaseWith;
}
export const ImportModal = ({database}: ImportModalProps) => {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" onClick={() => setOpen(true)}>
<Import/>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Import Backup</DialogTitle>
<DialogDescription>
Import backup file of your choice.
</DialogDescription>
<Separator className="mt-3 "/>
<UploadBackupZone
onSuccessAction={() => setOpen(false)}
databaseId={database.id}/>
</DialogHeader>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,108 @@
"use client";
import {DropZoneFile} from "@/components/wrappers/common/dropzone/dropzone-file";
import {useMutation} from "@tanstack/react-query";
import {useState} from "react";
import {Loader2} from "lucide-react";
import {useRouter} from "next/navigation";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {toast} from "sonner";
import {uploadBackupAction} from "@/components/wrappers/dashboard/database/import/upload-backup.action";
import {Card, CardContent} from "@/components/ui/card";
type UploadRetentionZoneProps = {
onSuccessAction?: () => void;
databaseId: string;
};
export const UploadBackupZone = ({onSuccessAction, databaseId}: UploadRetentionZoneProps) => {
const router = useRouter();
const [isProcessing, setIsProcessing] = useState(false);
const [file, setFile] = useState<File | null>(null);
const mutationUpload = useMutation({
mutationFn: async (file: File) => {
try {
setIsProcessing(true);
const formData = new FormData();
formData.append("file", file);
formData.append("databaseId", databaseId);
const result = await uploadBackupAction(formData)
const inner = result?.data;
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
onSuccessAction?.()
router.refresh();
} else {
toast.error(inner?.actionError?.message);
}
} catch (err) {
console.error(err);
toast.error("An error occurred while upload in the backup");
} finally {
router.refresh();
setIsProcessing(false);
}
},
});
const acceptDbImportFiles: Record<string, string[]> = {
"application/sql": [".sql"],
"application/x-sql": [".sql"],
"text/plain": [".sql"],
"application/octet-stream": [".dump"],
};
return (
<>
{isProcessing ? (
<UploadLoader label="Uploading database backup…"/>
) : (
<DropZoneFile
accept={acceptDbImportFiles}
maxSize={500 * 1024 * 1024}
maxFiles={1}
description="Import database backup"
fileKind="Database file (.sql, .dump)"
dragMessage="Click or drag a database dump here"
onFileDropAction={(file: File) => setFile(file)}
/>
)}
{(!isProcessing && file) && (
<div className="flex gap-4 justify-end">
<ButtonWithLoading
onClick={() => mutationUpload.mutateAsync(file)}
isPending={false}>
Upload
</ButtonWithLoading>
</div>
)}
</>
);
};
const UploadLoader = ({label = "Processing uploading..."}: { label?: string }) => {
return (
<Card className="w-full border">
<CardContent className="flex flex-col items-center justify-center gap-3 py-10 text-muted-foreground">
<div className="relative">
<Loader2 className="w-8 h-8 animate-spin inset-0"/>
</div>
<p className="text-sm font-medium">{label}</p>
<p className="text-xs text-muted-foreground">
Do not close this window
</p>
</CardContent>
</Card>
);
};
@@ -0,0 +1,113 @@
"use server"
import {userAction} from "@/lib/safe-actions/actions";
import {ServerActionResult} from "@/types/action-type";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {Backup} from "@/db/schema/07_database";
import {getFileExtension} from "../../../../../../app/api/agent/[agentId]/backup/helpers";
import {v4 as uuidv4} from "uuid";
import {eq} from "drizzle-orm";
import {uploadLocalPrivate, uploadS3Private} from "@/features/upload/private/upload.action";
import {z} from "zod";
import {env} from "@/env.mjs";
export const uploadBackupAction = userAction
.schema(z.instanceof(FormData))
.action(async ({parsedInput: formData}): Promise<ServerActionResult<Backup>> => {
try {
const file = formData.get("file") as File;
const databaseId = formData.get("databaseId") as string;
const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.id, databaseId),
with: {
project: true,
alertPolicies: true
}
});
if (!database) {
return {
success: false,
actionError: {
message: "Database does not exist",
status: 500,
cause: "Unknown error",
},
};
}
const fileExtension = getFileExtension(database.dbms)
const arrayBuffer = await file.arrayBuffer();
const fileSize = file.size;
const uuid = uuidv4();
const fileName = `imported_${uuid}${fileExtension}`;
const buffer = Buffer.from(arrayBuffer);
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
if (!settings) {
return {
success: false,
actionError: {
message: "Settings not set",
status: 500,
cause: "Unknown error",
},
};
}
let success: boolean, message: string, filePath: string;
const result =
settings.storage === "local"
? await uploadLocalPrivate(fileName, buffer)
: await uploadS3Private(`${database.project?.slug}/${fileName}`, buffer, env.S3_BUCKET_NAME!);
({success, message, filePath} = result);
if (!success) {
return {
success: false,
actionError: {
message: "An error has occurred while uploading file",
status: 500,
cause: "Unknown error",
},
};
}
const [backup] = await db
.insert(drizzleDb.schemas.backup)
.values({
status: 'success',
databaseId: database.id,
file: fileName,
fileSize: fileSize,
})
.returning();
return {
success: true,
value: backup,
actionSuccess: {
message: `Backup successfully imported`,
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed to import backup.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
@@ -13,9 +13,12 @@ export function OrganizationCombobox() {
const {data: activeOrganization, refetch: refetchActiveOrga} = authClient.useActiveOrganization();
const [openModal, setOpenModal] = useState(false);
if (!organizations) return null;
// if (!organizations) return null;
// const values = organizations.map(org => ({ value: org.slug, label: org.name }));
const values = organizations?.map(org => ({ value: org.slug, label: org.name })) ?? [];
const values = organizations.map(org => ({ value: org.slug, label: org.name }));
const onValueChange = async (slug: string) => {
await authClient.organization.setActive({ organizationSlug: slug });
@@ -28,6 +28,7 @@ import {
import {toast} from "sonner";
import {User as BetterAuthUser} from "better-auth";
import {User} from "@/db/schema/02_user";
import {authClient} from "@/lib/auth/auth-client";
export type organizationFormProps = {
defaultValues?: OrganizationWithMembers;
@@ -36,6 +37,8 @@ export type organizationFormProps = {
};
export const OrganizationForm = (props: organizationFormProps) => {
const {data: activeOrganization, refetch: refetchActiveOrga} = authClient.useActiveOrganization();
const {data: organizations, refetch} = authClient.useListOrganizations();
const router = useRouter();
const isCreate = !Boolean(props.defaultValues);
@@ -75,6 +78,8 @@ export const OrganizationForm = (props: organizationFormProps) => {
onSuccess: async (result) => {
if (result?.data?.success) {
toast.success(result.data.actionSuccess?.message || "Organization updated successfully.");
refetch()
refetchActiveOrga()
router.push("/dashboard/settings");
} else {
// @ts-ignore
@@ -4,7 +4,7 @@ import { UploadIcon } from "lucide-react";
import { toast } from "sonner";
import { uploadImageAction } from "@/features/upload/public/upload.action";
import { useMutation } from "@tanstack/react-query";
import { updateImageUserAction } from "@/components/wrappers/dashboard/profile2/avatar/avatar.action";
import { updateImageUserAction } from "@/components/wrappers/dashboard/profile/actions/avatar.action";
import { useRouter } from "next/navigation";
import { User } from "@/db/schema/02_user";
import React, {ChangeEvent} from "react";
@@ -1,6 +1,6 @@
"use client";
import {useState} from "react";
import React, {useState} from "react";
import {Button} from "@/components/ui/button";
import {
Dialog,
@@ -24,6 +24,7 @@ import z from "zod";
import {zPassword} from "@/lib/zod";
import {BackupCodesList} from "../components/backup-codes-list";
import {PasswordInput} from "@/components/ui/password-input";
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
const PasswordSchema = z.object({
password: zPassword(),
@@ -34,9 +35,10 @@ type Password = z.infer<typeof PasswordSchema>;
type Setup2FAModalProps = {
onOpenChange: (open: boolean) => void;
open: boolean;
disabled: boolean;
};
export function Setup2FAProfileProviderModal({onOpenChange, open}: Setup2FAModalProps) {
export function Setup2FAProfileProviderModal({onOpenChange, open, disabled}: Setup2FAModalProps) {
const router = useRouter();
const [step, setStep] = useState<"PASSWORD" | "QR" | "BACKUP">("PASSWORD");
@@ -113,10 +115,9 @@ export function Setup2FAProfileProviderModal({onOpenChange, open}: Setup2FAModal
};
return (
<Dialog open={open} onOpenChange={(v) => (!v ? handleClose() : onOpenChange(v))}>
<DialogTrigger asChild>
<DialogTrigger asChild disabled={disabled}>
<Button variant="outline" size="sm">
<ShieldCheck className="w-4 h-4 mr-2"/>
Enable Two-Factor
@@ -177,7 +178,8 @@ export function Setup2FAProfileProviderModal({onOpenChange, open}: Setup2FAModal
</div>
<div className="w-full space-y-2">
<p className="text-xs text-muted-foreground text-center">If you are unable to scan the QR code, you can manually enter the secret key into your authentication app :</p>
<p className="text-xs text-muted-foreground text-center">If you are unable to scan the
QR code, you can manually enter the secret key into your authentication app :</p>
<div className="flex items-center gap-2">
<code
className="flex-1 bg-muted p-2 rounded text-xs font-mono break-all text-center">{secret}</code>
@@ -237,7 +239,8 @@ export function Setup2FAProfileProviderModal({onOpenChange, open}: Setup2FAModal
className="border-green-200 bg-green-50 dark:bg-green-900/20 dark:border-green-900">
<CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-400"/>
<AlertDescription
className="text-green-700 dark:text-green-400">Two Factor Authentication is now enabled on your account.</AlertDescription>
className="text-green-700 dark:text-green-400">Two Factor Authentication is now enabled
on your account.</AlertDescription>
</Alert>
<BackupCodesList codes={backupCodes}/>
@@ -40,7 +40,7 @@ function ThemeSelector() {
)}
onClick={async () => {
// setTheme(item.value)
// await authClient.updateUser({theme: item.value});
await authClient.updateUser({theme: item.value});
}}
>
<div
@@ -12,48 +12,56 @@ import {useRouter} from "next/navigation";
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
import {Alert, AlertDescription} from "@/components/ui/alert";
import {SetPasswordProfileProviderModal} from "./modal/set-password-modal";
import {SUPPORTED_PROVIDERS} from "../../../../../portabase.config";
import {AuthProviderConfig} from "../../../../../portabase.config";
import {Icon} from "@iconify/react";
interface ProfileProviderProps {
accounts: Account[];
providers: AuthProviderConfig[]
}
export function ProfileProviders({accounts}: ProfileProviderProps) {
export function ProfileProviders({accounts, providers}: ProfileProviderProps) {
const router = useRouter();
const totalConnected = accounts.length;
const [loadingProvider, setLoadingProvider] = useState<string | null>(null);
const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false);
const {mutate: unlinkAccount, isPending: isUnlinking} = useMutation({
const {mutate: unlinkAccount} = useMutation({
mutationFn: async (providerId: string) => {
const {error} = await authClient.unlinkAccount({
providerId,
});
setLoadingProvider(providerId);
const {error} = await authClient.unlinkAccount({ providerId });
if (error) throw error;
},
onSuccess: () => {
toast.success("Provider successfully unlinked!");
setLoadingProvider(null);
router.refresh();
},
onError: () => {
toast.error("An error occurred while unlinking provider.");
setLoadingProvider(null);
},
});
const {mutate: linkAccount, isPending: isLinking} = useMutation({
const {mutate: linkAccount} = useMutation({
mutationFn: async (providerId: string) => {
setLoadingProvider(providerId);
const {error} = await authClient.signIn.social({
provider: providerId as "google" | "github",
provider: providerId as "google" | "github" | "credential",
callbackURL: "/dashboard",
});
if (error) throw error;
},
onSuccess: () => {
toast.success("Provider successfully Linked!");
setLoadingProvider(null);
router.refresh();
},
onError: () => {
toast.error("An error occurred while linked provider.");
toast.error("An error occurred while linking provider.");
setLoadingProvider(null);
},
});
@@ -65,19 +73,20 @@ export function ProfileProviders({accounts}: ProfileProviderProps) {
</div>
<div className="grid gap-4">
{SUPPORTED_PROVIDERS.map((provider) => {
{providers.map((provider) => {
const linkedAccount = accounts.find((acc) => acc.providerId === provider.id);
const isConnected = !!linkedAccount;
const canUnlink = totalConnected > 1 || (totalConnected === 1 && !provider.isManual);
const isLoading = isUnlinking || isLinking;
// const canUnlink = totalConnected > 1 || (totalConnected === 1 && !provider.isManual);
const canUnlink = totalConnected > 1 ;
// const isLoading = isUnlinking || isLinking;
const isLoading = loadingProvider === provider.id;
return (
<div key={provider.id}
className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted/30 transition-colors">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center">
<provider.icon className="w-5 h-5"/>
<Icon icon={provider.icon} className="w-5 h-5"/>
</div>
<div className="space-y-0.5">
<div className="font-medium flex items-center gap-2">
@@ -74,11 +74,9 @@ export function ProfileSecurity({user, sessions, credentialAccount, currentSessi
<div className="space-y-1">
<div className="font-medium">Password</div>
<div className="text-sm text-muted-foreground">
{/*{user.lastChangedPasswordAt*/}
{/* ? t("sections.authentication.password.description.last_changed", {*/}
{/* date: timeAgo(new Date(user.lastChangedPasswordAt), locale),*/}
{/* })*/}
{/* : "Never changed"}*/}
{user.lastChangedPasswordAt
? `Last changed ${timeAgo(new Date(user.lastChangedPasswordAt))}`
: "Never changed"}
</div>
</div>
{credentialAccount ? (
@@ -116,8 +114,11 @@ export function ProfileSecurity({user, sessions, credentialAccount, currentSessi
onOpenChange={setIsDisable2FADialogOpen}/>
</div>
) : (
<Setup2FAProfileProviderModal open={isSetup2FADialogOpen}
onOpenChange={setIsSetup2FADialogOpen}/>
<Setup2FAProfileProviderModal
disabled={!credentialAccount}
open={isSetup2FADialogOpen}
onOpenChange={setIsSetup2FADialogOpen}
/>
)}
</div>
</div>
@@ -1,80 +0,0 @@
"use client";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { UploadIcon } from "lucide-react";
import { toast } from "sonner";
import { uploadImageAction } from "@/features/upload/public/upload.action";
import { useMutation } from "@tanstack/react-query";
import { updateImageUserAction } from "@/components/wrappers/dashboard/profile2/avatar/avatar.action";
import { useRouter } from "next/navigation";
import { User } from "@/db/schema/02_user";
import {ChangeEvent} from "react";
export type AvatarWithUploadProps = {
user: User;
};
export const AvatarWithUpload = (props: AvatarWithUploadProps) => {
const user = props.user;
const router = useRouter();
const submitImage = useMutation({
mutationFn: async (file: File) => {
const formData = new FormData();
formData.set("file", file);
const uploadImage = await uploadImageAction(formData);
const data = uploadImage?.data?.data;
if (uploadImage?.serverError || !data) {
console.log(uploadImage?.serverError);
toast.error(uploadImage?.serverError);
return;
}
const updateUser = await updateImageUserAction(data.url);
const dataUser = updateUser?.data?.data;
if (updateUser?.serverError || !dataUser) {
console.log(updateUser?.serverError);
toast.error(updateUser?.serverError);
return;
}
toast.success("Successfully uploaded user image!");
router.refresh();
},
});
const handleImageUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.includes("image")) {
toast.error("File not an image");
return;
}
submitImage.mutate(file);
};
return (
<div className="relative ">
<Avatar className="size-14 mr-3 ">
<AvatarFallback>{user.name[0]}</AvatarFallback>
{user.image ? <AvatarImage src={user.image} alt={`${user.name ?? "-"}'s profile picture`} /> : null}
</Avatar>
<div
onClick={() => {
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = "image/*";
// @ts-ignore
fileInput.onchange = handleImageUpload;
fileInput.click();
}}
className="cursor-pointer absolute inset-0 flex justify-center items-center opacity-0 transition-opacity hover:opacity-100 hover:bg-gray-500 hover:bg-opacity-50 rounded-full size-14"
>
<UploadIcon className="w-8 h-8 text-primary" />
</div>
</div>
);
};
@@ -1,15 +0,0 @@
"use server";
import { db } from "@/db";
import {userAction} from "@/lib/safe-actions/actions";
import { eq } from "drizzle-orm";
import { z } from "zod";
import * as drizzleDb from "@/db";
export const updateImageUserAction = userAction.schema(z.string()).action(async ({ parsedInput, ctx }) => {
const [updatedUser] = await db.update(drizzleDb.schemas.user).set({ image: parsedInput }).where(eq(drizzleDb.schemas.user.id, ctx.user.id)).returning();
return {
data: updatedUser,
};
});
@@ -58,4 +58,4 @@ export const ButtonDeleteAccount = (props: ButtonDeleteAccountProps) => {
);
};
};
@@ -33,4 +33,4 @@ export const deleteUserAction = userAction.schema(z.string()).action(async ({ pa
return {
data: deletedUser,
};
});
});
@@ -1,22 +0,0 @@
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { currentUser } from "@/lib/auth/current-user";
import { notFound } from "next/navigation";
export type UserAvatarProps = {};
export const UserAvatar = async () => {
const user = await currentUser();
if (!user) {
return notFound();
}
return (
<div>
<Avatar className="size-6">
<AvatarFallback>{user.name[0]}</AvatarFallback>
{user.image ? <AvatarImage src={user.image} alt={`${user.name ?? "-"}'s profile picture`} /> : null}
</Avatar>
</div>
);
};
@@ -1,122 +0,0 @@
"use client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Form } from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import { useRouter } from "next/navigation";
import { useMutation } from "@tanstack/react-query";
import { TooltipProvider } from "@/components/ui/tooltip";
import { UserSchema, UserType } from "@/components/wrappers/dashboard/profile2/user-form/user-form.schema";
import { toast } from "sonner";
import { updateUserAction } from "@/components/wrappers/dashboard/profile2/user-form/user-form.action";
import {DataTable} from "@/components/wrappers/common/table/data-table";
import {sessionsColumns} from "@/components/wrappers/dashboard/admin/users/sessions/table-columns";
import {accountsColumns} from "@/components/wrappers/dashboard/admin/users/accounts/table-columns";
import {Account, Session} from "better-auth";
export type UserFormProps = {
defaultValues?: UserType;
userId?: string;
sessions: Session[];
accounts?: Account[];
};
export const UserForm = (props: UserFormProps) => {
const isCreate = !Boolean(props.defaultValues);
const form = useZodForm({
schema: UserSchema,
defaultValues: props.defaultValues,
});
const router = useRouter();
const mutation = useMutation({
mutationFn: async (values: UserType) => {
const updateUser = await updateUserAction({
id: props.userId ?? "-",
data: values,
});
const data = updateUser?.data?.data;
if (updateUser?.serverError || !data) {
toast.error(updateUser?.serverError);
return;
}
toast.success(`Profile updated successfully.`);
router.refresh();
router.push(`/dashboard/profile`);
},
});
return (
<div className="flex flex-col gap-y-4 h-full py-4">
<TooltipProvider>
<Card>
<CardHeader>
<CardTitle>Account</CardTitle>
<CardDescription>Your informations</CardDescription>
</CardHeader>
<CardContent>
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder={"Your Name"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder={"exemple@portabase.com"} disabled {...field} value={field.value ?? ""} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button>{isCreate ? `` : `Save`}</Button>
</Form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Active sessions</CardTitle>
<CardDescription>Manage your active sessions</CardDescription>
</CardHeader>
<CardContent>
<DataTable columns={sessionsColumns} data={props.sessions} enableSelect={false}/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Auth providers</CardTitle>
<CardDescription>Manage your active auth providers</CardDescription>
</CardHeader>
<CardContent>
<DataTable columns={accountsColumns} data={props.accounts ?? []} enableSelect={false}/>
</CardContent>
</Card>
</TooltipProvider>
</div>
);
};
@@ -38,8 +38,6 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
if (!props.backups) return [];
return props.backups.filter(backup => {
// --- Status Filter ---
if (selectedFilters.length > 0) {
const selectedValues = selectedFilters.map(f => f.value);
const status = backup.deletedAt != null ? "deleted" : "available";
@@ -1,47 +1,45 @@
"use client";
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
import { humanReadableDate } from "@/utils/date-formatting";
import {ChartConfig, ChartTooltip} from "@/components/ui/chart";
import {CartesianGrid, TooltipProps, XAxis, YAxis} from "recharts";
import {formatDayOnly} from "@/utils/date-formatting";
import {LineChartCustom} from "@/components/wrappers/dashboard/statistics/charts/line-chart";
import {generateFakeEvolutionData} from "@/components/wrappers/dashboard/statistics/charts/fake-data";
type Data = {
createdAt: Date;
};
export type evolutionLineChartProps = {
data: Data[];
type Payload = {
date: string;
count: number;
};
export function EvolutionLineChart(props: evolutionLineChartProps) {
const { data } = props;
type LineChartDatum = {
date: string
count: number
}
export type EvolutionLineChartProps = {
data: Data[];
};
export function EvolutionLineChart(props: EvolutionLineChartProps) {
const {data} = props;
// const fakeData = generateFakeEvolutionData(14, 2, 10)
// const cumulativeData = data.reduce(
// (acc, backup) => {
// const date = backup.createdAt.toISOString().split("T")[0]; // Format as YYYY-MM-DD
//
// // Increment count for the current date or initialize it
// if (acc.length && acc[acc.length - 1].date === date) {
// acc[acc.length - 1].count += 1;
// } else {
// const lastCount = acc.length ? acc[acc.length - 1].count : 0;
// acc.push({ date, count: lastCount + 1 });
// }
//
// return acc;
// },
// [] as { date: string; count: number }[]
// );
const dailyData = data
.reduce((acc, backup) => {
const date = backup.createdAt.toISOString().split("T")[0]; // Format: YYYY-MM-DD
const date = backup.createdAt.toISOString().split("T")[0];
// Find if the date already exists in the accumulator
const existing = acc.find(item => item.date === date);
if (existing) {
existing.count += 1;
} else {
acc.push({ date, count: 1 });
acc.push({date, count: 1});
}
return acc;
@@ -51,40 +49,66 @@ export function EvolutionLineChart(props: evolutionLineChartProps) {
const chartConfig = {
date: {
label: "Date",
color: "#2563eb",
},
count: {
label: "Number of backups",
color: "#60a5fa",
label: "Number of backups ",
},
} satisfies ChartConfig;
return (
<ChartContainer config={chartConfig}>
<LineChart
accessibilityLayer
data={dailyData}
margin={{
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => {
return humanReadableDate(new Date(value)).split(" ")[0]
}}
/>
<YAxis />
<ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel />} />
<Line dataKey="count" type="linear" stroke="#60a5fa" strokeWidth={2} dot={false} />
{/*<Line dataKey="count" type="linear" stroke="var(--color-desktop)" strokeWidth={2} dot={false} />*/}
</LineChart>
</ChartContainer>
<LineChartCustom<LineChartDatum>
config={chartConfig}
data={dailyData}
title="Evolution of the number of backups"
dataKey="count"
>
<CartesianGrid vertical={false}/>
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) =>
formatDayOnly(new Date(value))
}
/>
<YAxis tickLine={false}/>
<ChartTooltip
cursor={{strokeDasharray: "3 3"}}
content={<EvolutionTooltip/>}
/>
</LineChartCustom>
);
}
function EvolutionTooltip({
active,
payload,
}: TooltipProps<number, string>) {
if (!active || !payload || payload.length === 0) return null;
const data = payload[0].payload as Payload;
return (
<div className="rounded-lg border bg-background px-3 py-2 shadow-md">
<p className="text-sm font-medium">
{formatDayOnly(new Date(data.date))}
</p>
<div className="mt-1 flex items-center gap-2 text-sm">
<span className="h-2 w-2 rounded-full bg-[#fc6504]"/>
<span className="text-muted-foreground">Backups :</span>
<span className="ml-auto font-semibold">
{data.count}
</span>
</div>
</div>
);
}
@@ -0,0 +1,42 @@
type Data = {
createdAt: Date;
};
/**
* Generate fake backup events over a time range.
*
* @param days Number of days to generate
* @param minPerDay Minimum events per day
* @param maxPerDay Maximum events per day
*/
export function generateFakeEvolutionData(
days: number = 30,
minPerDay: number = 1,
maxPerDay: number = 8
): Data[] {
const result: Data[] = [];
const now = new Date();
for (let d = 0; d < days; d++) {
const day = new Date(now);
day.setDate(now.getDate() - d);
const events =
Math.floor(Math.random() * (maxPerDay - minPerDay + 1)) + minPerDay;
for (let i = 0; i < events; i++) {
const createdAt = new Date(day);
createdAt.setHours(
Math.floor(Math.random() * 24),
Math.floor(Math.random() * 60),
Math.floor(Math.random() * 60)
);
result.push({ createdAt });
}
}
return result.sort(
(a, b) => a.createdAt.getTime() - b.createdAt.getTime()
);
}
@@ -0,0 +1,99 @@
import {ChartConfig, ChartContainer} from "@/components/ui/chart";
import {Line, LineChart} from "recharts";
import {ReactNode, useState} from "react";
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {PlaceholderChart} from "@/components/wrappers/dashboard/statistics/charts/utils/placeholder";
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
type LineChartCustomProps<T> = {
config: ChartConfig,
data: T[],
children: ReactNode,
title: string,
dataKey: string,
margin?: { left: number; right: number }
}
export const LineChartCustom = <T extends { date: string }>({
config,
title,
data,
children,
dataKey,
margin
}: LineChartCustomProps<T>) => {
const [timeRange, setTimeRange] = useState("7d")
const filteredData = data.filter((item) => {
const date = new Date(item.date)
const referenceDate = new Date()
let daysToSubtract = 90
if (timeRange === "30d") {
daysToSubtract = 30
} else if (timeRange === "7d") {
daysToSubtract = 7
}
const startDate = new Date(referenceDate)
startDate.setDate(startDate.getDate() - daysToSubtract)
return date >= startDate
})
return (
<Card className="w-full">
<CardHeader className="flex items-center gap-4 space-y-0 border-b py-0 sm:flex-row">
<CardTitle className="text-sm md:text-lg">{title}</CardTitle>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger
className=" rounded-lg sm:ml-auto sm:flex"
aria-label="Select a value"
>
<SelectValue placeholder="Last 3 months"/>
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="7d" className="rounded-lg">
Last 7 days
</SelectItem>
<SelectItem value="30d" className="rounded-lg">
Last 30 days
</SelectItem>
<SelectItem value="90d" className="rounded-lg">
Last 3 months
</SelectItem>
</SelectContent>
</Select>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6 md:pt-0 md:px-6 h-full w-full">
{data.length > 0 ? (
<ChartContainer config={config}>
<LineChart
accessibilityLayer
data={filteredData}
margin={margin ? margin : {
left: -35,
right: 12,
}}
>
{children}
<Line
dataKey={dataKey}
type="linear"
strokeWidth={2}
stroke="#fc6504"
dot={false}
/>
</LineChart>
</ChartContainer>
) : (
<PlaceholderChart text="No data available"/>
)}
</CardContent>
</Card>
)
}
@@ -1,8 +1,10 @@
"use client";
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
import { EStatusSchema } from "@/db/schema/types";
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
import {ChartConfig, ChartTooltip} from "@/components/ui/chart";
import {EStatusSchema} from "@/db/schema/types";
import {LineChartCustom} from "@/components/wrappers/dashboard/statistics/charts/line-chart";
import {CartesianGrid, TooltipProps, XAxis, YAxis} from "recharts";
import {formatDayOnly} from "@/utils/date-formatting";
type Data = {
createdAt: Date;
@@ -10,31 +12,26 @@ type Data = {
_count: number;
};
type Payload = {
date: string
successRate: number
}
export type percentageLineChartProps = {
data: Data[];
};
export function PercentageLineChart(props: percentageLineChartProps) {
const { data } = props;
const {data} = props;
const chartConfig = {
date: {
label: "Date",
color: "#2563eb",
},
successRate: {
label: "Success Rate",
color: "#60a5fa",
},
} satisfies ChartConfig;
const dailyStats = data.reduce(
(acc, backup) => {
const date = backup.createdAt.toISOString().split("T")[0]; // Format YYYY-MM-DD
const date = backup.createdAt.toISOString().split("T")[0];
const status = backup.status;
if (!acc[date]) {
acc[date] = { success: 0, failed: 0, total: 0 };
acc[date] = {success: 0, failed: 0, total: 0};
}
acc[date][status === "success" ? "success" : "failed"] += backup._count;
@@ -45,34 +42,81 @@ export function PercentageLineChart(props: percentageLineChartProps) {
{} as Record<string, { success: number; failed: number; total: number }>
);
// Format data for the chart
const formattedData = Object.entries(dailyStats).map(([date, stats]) => ({
date,
successRate: (stats.success / stats.total) * 100,
}));
const chartConfig = {
date: {
label: "Date",
},
successRate: {
label: "Success Rate",
},
} satisfies ChartConfig;
return (
<ChartContainer config={chartConfig}>
<LineChart accessibilityLayer data={formattedData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis domain={[0, 100]} tickFormatter={(tick) => `${tick}%`} />
<ChartTooltip
content={<ChartTooltipContent />}
cursor={false}
defaultIndex={1}
formatter={(value, name) => (
<div className="flex min-w-[130px] items-center text-xs text-muted-foreground">
{chartConfig[name as keyof typeof chartConfig]?.label || name}
<div className="ml-auto flex items-baseline gap-0.5 font-mono font-medium tabular-nums text-foreground">
{value}
<span className="font-normal text-muted-foreground">%</span>
</div>
</div>
)}
/>
<Line type="step" dataKey="successRate" stroke="#8884d8" strokeWidth={2} />
</LineChart>
</ChartContainer>
<LineChartCustom<Payload>
title="Success rate of backups"
config={chartConfig}
data={formattedData}
dataKey="successRate"
margin={{
left: -15,
right: 12,
}}
>
{/*<CartesianGrid strokeDasharray="3 3" />*/}
<CartesianGrid vertical={false}/>
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) =>
formatDayOnly(new Date(value))
}
/>
<YAxis
tickLine={false}
domain={[0, 100]}
tickFormatter={(tick) => `${tick}%`}
/>
<ChartTooltip
defaultIndex={1}
cursor={{strokeDasharray: "3 3"}}
content={<PourcentTooltip/>}
/>
</LineChartCustom>
);
}
function PourcentTooltip({
active,
payload,
}: TooltipProps<number, string>) {
if (!active || !payload || payload.length === 0) return null;
const data = payload[0].payload as Payload;
return (
<div className="rounded-lg border bg-background px-3 py-2 shadow-md">
<p className="text-sm font-medium">
{formatDayOnly(new Date(data.date))}
</p>
<div className="mt-1 flex items-center gap-2 text-sm">
<span className="h-2 w-2 rounded-full bg-[#fc6504]"/>
<span className="text-muted-foreground">Success Rate :</span>
<span className="ml-auto font-semibold">
{data.successRate} %
</span>
</div>
</div>
);
}
@@ -0,0 +1,5 @@
export const PlaceholderChart = ({text}: { text: string }) => (
<div className="flex h-100 w-full items-center justify-center text-sm text-muted-foreground">{text}</div>
);
@@ -0,0 +1,24 @@
CREATE TABLE "passkey" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text,
"publicKey" text NOT NULL,
"userId" uuid NOT NULL,
"credentialId" text NOT NULL,
"counter" integer NOT NULL,
"deviceType" text NOT NULL,
"backedUp" boolean NOT NULL,
"transports" text,
"updated_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
"deleted_at" timestamp
);
--> statement-breakpoint
CREATE TABLE "two_factor" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"secret" text NOT NULL,
"backup_codes" text NOT NULL,
"user_id" uuid NOT NULL
);
--> statement-breakpoint
ALTER TABLE "passkey" ADD CONSTRAINT "passkey_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "two_factor" ADD CONSTRAINT "two_factor_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
@@ -0,0 +1,2 @@
CREATE TYPE "public"."user_themes" AS ENUM('light', 'dark', 'system');--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "theme" "user_themes" DEFAULT 'light' NOT NULL;
@@ -0,0 +1 @@
ALTER TABLE "backups" ADD COLUMN "file_size" integer;
File diff suppressed because it is too large Load Diff

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