Compare commits

..
46 Commits
Author SHA1 Message Date
charlesgauthereau 86f50e5741 Bug in profile. 2025-12-21 19:41:30 +01:00
charlesgauthereau a6e52fd03d Ready for 1.1.5-rc.4 2025-12-21 19:27:00 +01:00
charlesgauthereau 116229a29e Start working on the profile refactoring. 2025-12-21 16:55:12 +01:00
charlesgauthereau 428782e8a4 Ready for 1.1.5-rc.2. Patching an api bug. 2025-12-21 11:57:07 +01:00
charlesgauthereau b8442803d9 Ready for 1.1.5-rc.1 2025-12-21 11:25:33 +01:00
charlesgauthereau 82ab2c63d6 File refactoring and react errors. 2025-12-21 09:59:49 +01:00
charlesgauthereau e953de0e06 For release 1.1.4 in main 2025-12-09 21:31:46 +01:00
Soluce TechnologiesandGitHub f6d927f866 Merge pull request #1 from Soluce-Technologies/dependabot/npm_and_yarn/next-16.0.7
Bump next from 15.5.2 to 16.0.7
2025-12-09 21:29:58 +01:00
dependabot[bot]andGitHub 3773c4b068 Bump next from 15.5.2 to 16.0.7
Bumps [next](https://github.com/vercel/next.js) from 15.5.2 to 16.0.7.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v15.5.2...v16.0.7)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-09 20:28:45 +00:00
charlesgauthereau bb0dbdadae For release 1.1.4 in main 2025-12-09 21:25:29 +01:00
charlesgauthereau dd8aec50eb For release 1.1.4 in main 2025-12-09 21:23:15 +01:00
charlesgauthereau 7410aaea38 Hiding notifiers if not admin or owner in organization. 2025-12-08 09:57:17 +01:00
charlesgauthereau 0f9bbbda3c Ready for release 1.1.4 2025-12-07 12:25:10 +01:00
charlesgauthereau 25f9bc6e79 Ready for release 1.1.4 2025-12-07 12:11:00 +01:00
charlesgauthereau caf00b7877 Ready for release 1.1.4 2025-12-07 11:48:14 +01:00
charlesgauthereau 9c0c462192 Ready for release 1.1.4 2025-12-07 11:22:07 +01:00
charlesgauthereau ef7570db76 Ready for release 1.1.4 2025-12-07 11:15:43 +01:00
charlesgauthereau e1bc1ec6c8 Ready for release 1.1.4 2025-12-07 10:53:08 +01:00
charlesgauthereau ca232250cc Working on notification system. 2025-12-06 23:18:59 +01:00
charlesgauthereau fde7688485 Working on notification system. 2025-12-06 23:14:59 +01:00
charlesgauthereau 9458b12d69 Refactoring the sidebar. 2025-12-06 15:50:23 +01:00
charlesgauthereau 5cf6cf075e Working on notifications channels. 2025-11-30 12:21:21 +01:00
charlesgauthereau 8980e6d4a8 Working on notifications channels. 2025-11-29 23:12:06 +01:00
charlesgauthereau ac951378e8 Some refactoring in database and projects side. 2025-11-29 22:31:32 +01:00
charlesgauthereau 934e635cdb Working on implementation of alert policies. Some refactoring in user profile and patching a bug in status endpoint api. 2025-11-28 23:10:37 +01:00
charlesgauthereau d968aa9c16 Working on implementation of alert policies. 2025-11-23 17:13:07 +01:00
charlesgauthereau eb471c7cdb Working on UX/UI of the notification system. 2025-11-22 19:36:31 +01:00
charlesgauthereau e886bdd58d Improved the OrganizationCombobox. 2025-11-22 18:45:24 +01:00
charlesgauthereau fe5d8bae1b Adding reset password feature. 2025-11-22 17:20:52 +01:00
charlesgauthereau aabdea06a9 Merge branch 'main' into feature
# Conflicts:
#	app/(auth)/login/page.tsx
2025-11-22 14:18:50 +01:00
Soluce TechnologiesandGitHub 37a3ace161 Update env.mjs 2025-11-17 19:01:21 +01:00
Soluce TechnologiesandGitHub 933ab7d68a Update Login Page 2025-11-17 18:59:33 +01:00
charlesgauthereau f42cb37d67 Bug on auth google. 2025-11-11 10:32:27 +01:00
charlesgauthereau 7dfd9f02e8 Working on notifier system. 2025-11-09 16:47:58 +01:00
charlesgauthereau 411e09f452 Working on the README.md 2025-11-08 21:43:13 +01:00
charlesgauthereau f21149f42e Working on the README.md 2025-11-08 21:04:53 +01:00
charlesgauthereau 099b6fb0e8 Working on the README.md 2025-11-08 21:03:24 +01:00
charlesgauthereau eb2e0ef468 Working on the README.md 2025-11-08 20:07:54 +01:00
charlesgauthereau 4644ed2f41 Working on the README.md 2025-11-08 20:07:49 +01:00
charlesgauthereau cf8e5b7094 Ready for 1.1.3 2025-11-07 22:04:10 +01:00
charlesgauthereau b0775e82ea Updating the README.md 2025-11-04 11:38:22 +01:00
charlesgauthereau f9b3461a44 Ready for 1.1.3-rc.3 release. 2025-11-04 11:37:36 +01:00
charlesgauthereau c4ebc876bf Ready for 1.1.3-rc.3 release. 2025-11-04 09:20:24 +01:00
charlesgauthereau a435a92d41 Ready for 1.1.3-rc.3 release. 2025-11-04 08:48:46 +01:00
charlesgauthereau bcbd6bbea1 Work on passing all env variables on server side. 2025-11-03 18:54:16 +01:00
charlesgauthereau 82559b49bd Ready for 1.1.3-rc.2 and migrating on nextjs 16 2025-11-03 14:02:50 +01:00
246 changed files with 25821 additions and 3416 deletions
+4 -3
View File
@@ -5,9 +5,9 @@ NODE_ENV=production
DATABASE_URL=postgresql://devuser:changeme@db:5432/devdb?schema=public
# Projet
NEXT_PUBLIC_PROJECT_NAME="Portabase"
NEXT_PUBLIC_PROJECT_DESCRIPTION="Portabase is a powerful database manager"
NEXT_PUBLIC_PROJECT_URL=http://app.portabase.io
PROJECT_NAME="Portabase"
PROJECT_DESCRIPTION="Portabase is a powerful database manager"
PROJECT_URL=http://app.portabase.io
PROJECT_SECRET=
# SMTP (email)
@@ -20,6 +20,7 @@ SMTP_FROM=
# Google
AUTH_GOOGLE_ID=
AUTH_GOOGLE_SECRET=
AUTH_GOOGLE_METHOD=
# S3
S3_ENDPOINT=http://app.s3.portabase.io
Binary file not shown.
+139 -55
View File
@@ -4,69 +4,127 @@
<img src="/public/images/logo.png" alt="Logo" width="80" height="80">
</a>
<h3 align="center">Portabase</h3>
<p align="center">
Easily backup and restore your database instances with Portabase
<br />
<a href="https://portabase.io"><strong>Explore the Docs »</strong></a>
<br />
<br />
<a href="https://www.youtube.com/watch?v=D9uFrGxLc4s">View Demo</a>
·
<a href="https://github.com/Soluce-Technologies/portabase/issues/new?labels=bug&template=bug-report---.md">Report Bug</a>
·
<a href="https://github.com/Soluce-Technologies/portabase/issues/new?labels=enhancement&template=feature-request---.md">Request Feature</a>
![portabase-ezgif com-video-to-gif-converter](https://github.com/user-attachments/assets/e3929e58-fefd-44cc-ab26-6f8e1a860c43)
<h3 align="center">Portabase</h3>
<p>
Free, open-source, and self-hosted solution for automated backup and restoration of your database instances.
</p>
[![License: MIT](https://img.shields.io/badge/License-MIT-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)
[![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)
<p>
<strong>
<a href="https://portabase.io">Documentation</a> •
<a href="https://www.youtube.com/watch?v=D9uFrGxLc4s">Demo</a> •
<a href="#installation">Installation</a> •
<a href="#contributing">Contributing</a> •
<a href="https://github.com/Soluce-Technologies/portabase/issues/new?labels=bug&template=bug-report---.md">Report Bug</a> •
<a href="https://github.com/Soluce-Technologies/portabase/issues/new?labels=enhancement&template=feature-request---.md">Request Feature</a>
</strong>
</p>
![portabase-ezgif com-video-to-gif-converter](https://github.com/user-attachments/assets/e3929e58-fefd-44cc-ab26-6f8e1a860c43)
</div>
## 📚 Table of Contents
- [About The Project](#about-the-project)
- [Built With](#built-with)
- [Getting Started](#getting-started)
- [Prerequisites](#prerequisites)
- [Installation](#installation)
- [Usage](#usage)
- [Roadmap](#roadmap)
- [Contributing](#contributing)
- [License](#license)
- [Contact](#contact)
- [Acknowledgments](#acknowledgments)
- [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
## 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.
**Portabase** is a server dashboard tool designed to simplify the backup and restoration of your database instances. It
integrates seamlessly with Portabase agents for managing operations securely and efficiently.
GitHub Repository: [Portabase](https://github.com/Soluce-Technologies/portabase)
### 🔧 Built With
- [![NextJS][NextJS]][NextJS-url] (v15 with App Router)
- [![NextJS][NextJS]][NextJS-url] (v16 with App Router)
- [![Drizzle][Drizzle]][Drizzle-url]
- [![ShadcnUI][ShadcnUI]][ShadcnUI-url]
- BetterAuth
- React Email
- [![BetterAuth][BetterAuth]][BetterAuth-url]
- [![Docker][Docker]][Docker-url]
---
## 🚀 Getting Started
### Prerequisites
### Installation
Ensure Docker is installed on your machine before getting started.
### Installation
### Option 1: Docker Compose Setup
To run Portabase locally:
Create a `docker-compose.yml` file with the following configuration:
```yaml
name: portabase
services:
portabase:
image: solucetechnologies/portabase:latest
env_file:
- .env
ports:
- '8887:80'
environment:
- TIME_ZONE="Europe/Paris"
volumes:
- portabase-private:/app/private
depends_on:
db:
condition: service_healthy
container_name: portabase-app
db:
image: postgres:17-alpine
ports:
- "5433:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=<your_database>
- POSTGRES_USER=<database_user>
- POSTGRES_PASSWORD=<database_password>
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U <database_user> -d <your_database>" ]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres-data:
portabase-private:
```
Then run:
```bash
docker compose up -d
```
If you use reverse proxy like
Traefik : [Check this link](https://portabase.io/docs/portabase/advanced-topics/reverse-proxy)
### Option 2: Locally (Development)
1. Clone the repository:
```bash
@@ -82,13 +140,23 @@ To run Portabase locally:
## 🛠️ Usage
Portabase provides a dashboard to manage database instances and backups. It supports:
Portabase provides a web dashboard to manage your database instances and backups.
It currently supports:
- PostgreSQL (current)
- MongoDB (coming soon)
- MySQL (coming soon)
- **PostgreSQL**
- **MySQL**
You can access the dashboard at `http://localhost:3000` after starting the project.
### 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.
---
@@ -106,7 +174,8 @@ You can access the dashboard at `http://localhost:3000` after starting the proje
- [ ] Extend multi-database support:
- [x] PostgreSQL
- [ ] MongoDB
- [ ] MySQL
- [x] MySQL
- [x] MariaDB
Check out [open issues](https://github.com/Soluce-Technologies/portabase/issues) for more.
@@ -147,27 +216,28 @@ Give the project a ⭐ if you like it!
# Environment
NODE_ENV=production
# Database
# Database
DATABASE_URL=postgresql://devuser:changeme@db:5432/devdb?schema=public
# Project Info
NEXT_PUBLIC_PROJECT_NAME="Portabase"
NEXT_PUBLIC_PROJECT_DESCRIPTION="Portabase is a powerful database manager"
NEXT_PUBLIC_PROJECT_URL=http://app.portabase.io
# Project Info
PROJECT_NAME="Portabase"
PROJECT_DESCRIPTION="Portabase is a powerful database manager"
PROJECT_URL=http://app.portabase.io
PROJECT_SECRET=
# SMTP (Email)
# SMTP (Email)
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=
# Google OAuth
# Google OAuth
AUTH_GOOGLE_ID=
AUTH_GOOGLE_SECRET=
AUTH_GOOGLE_METHOD=
# S3/MinIO Configuration
# S3/MinIO Configuration
S3_ENDPOINT=http://app.s3.portabase.io
S3_ACCESS_KEY=
S3_SECRET_KEY=
@@ -175,13 +245,16 @@ S3_BUCKET_NAME=portabase
S3_PORT=9000
S3_USE_SSL=true
# Storage Backend: 'local' or 's3'
# Storage Backend: 'local' or 's3'
STORAGE_TYPE=local
# Retention
# Retention
RETENTION_CRON="* * * * *"
```
To get more information about env variables, check
that [link](https://portabase.io/docs/portabase/advanced-topics/environment)
### Semantic Versioning
Use the following format for Docker image versioning:
@@ -214,11 +287,22 @@ Distributed under the Apache License. See `LICENSE.txt` for more details.
Thanks to all contributors and the open-source community!
---
[Docker]: https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=fff&style=for-the-badge
[NextJS]: https://img.shields.io/badge/next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white
[BetterAuth]: https://img.shields.io/badge/Better%20Auth-FFF?logo=betterauth&logoColor=000&style=for-the-badge
[Drizzle]: https://img.shields.io/badge/Drizzle-111?style=for-the-badge&logo=Drizzle&logoColor=c5f74f
[ShadcnUI]: https://img.shields.io/badge/shadcn/ui-000000?style=for-the-badge&logo=shadcn/ui&logoColor=white
[NextJS-url]: https://nextjs.org/
[BetterAuth-url]: https://www.better-auth.com/
[Drizzle-url]: https://orm.drizzle.team/
[ShadcnUI-url]: https://ui.shadcn.com/
[Docker-url]: https://www.docker.com/
+23
View File
@@ -0,0 +1,23 @@
import { Card, 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";
export default async function RoutePage(props: { searchParams: Promise<{ callbackUrl: string | undefined }> }) {
return (
<TooltipProvider>
<Card className="w-full">
<CardHeader>
<div className="grid gap-2 text-center mb-2">
<h1 className="text-3xl font-bold">Reset password</h1>
<p className="text-balance text-muted-foreground">Enter your email address and we'll send you a link to reset your password.</p>
</div>
</CardHeader>
<CardContent>
<ForgotPasswordForm />
</CardContent>
</Card>
</TooltipProvider>
);
}
+32
View File
@@ -0,0 +1,32 @@
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";
export default async function GuardPage() {
const cookieStore = await cookies();
const token = cookieStore.get("better-auth.two_factor")?.value;
if (!token) {
redirect("/login");
}
return (
<TooltipProvider>
<Card className="w-full max-w-md">
<CardHeader>
<div className="grid gap-2 text-center mb-2">
<h1 className="text-3xl font-bold">Two-factor verification</h1>
<p className="text-balance text-muted-foreground">Please enter the verification code generated by your authentication app.</p>
</div>
</CardHeader>
<CardContent>
<GuardForm />
</CardContent>
</Card>
</TooltipProvider>
);
}
+9 -38
View File
@@ -1,49 +1,20 @@
"use client"
import React, {useEffect, useState} from "react";
import {LayoutAdmin} from "@/components/layout";
import Image from "next/image";
import {env} from "@/env.mjs";
import {useTheme} from "next-themes";
import {useSession} from "@/lib/auth/auth-client";
import {useRouter} from "next/navigation";
import React from "react";
import {redirect} from "next/navigation";
import {currentUser} from "@/lib/auth/current-user";
import {AuthLogoSection} from "@/components/wrappers/auth/auth-logo-section";
export default async function Layout({children}: { children: React.ReactNode }) {
export default function Layout({children}: { children: React.ReactNode }) {
const user = await currentUser();
const { resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const router = useRouter();
const { data: session } = useSession();
if (session && session.user && !session.user.banned && session.user.role !== "pending") {
router.replace("/dashboard/home");
if (user && !user.banned && user.role !== "pending") {
redirect("/dashboard/home");
}
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
const imageTheme = resolvedTheme === "dark" ? "/images/logo-white.png" : "/images/logo-black.png";
return (
<div className="flex min-h-full flex-1 flex-col justify-center py-12 sm:px-6 lg:px-8 ">
<div className="mx-auto w-full max-w-md">
<div className="sm:mx-auto sm:w-full sm:max-w-md flex items-center justify-center space-x-2">
<img
className="p-12 text-black dark:text-white"
src={imageTheme}
// loading="eager"
alt="Logo"
// width={1024}
// height={1024}
/>
<span
className="text-sm text-muted-foreground -ml-12 -mb-12">
v{env.NEXT_PUBLIC_PROJECT_VERSION}
</span>
</div>
<AuthLogoSection/>
<div>{children}</div>
</div>
<footer className="py-4 text-center text-xs justify-items-end text-muted-foreground">
+49 -26
View File
@@ -1,32 +1,55 @@
"use client"
import {useEffect, useRef, useState} from "react";
import {useSearchParams} from "next/navigation";
import {LoginForm} from "@/components/wrappers/auth/login/login-form/login-form";
import {toast} from "sonner";
import {Metadata} from "next";
import {SUPPORTED_PROVIDERS} from "../../../portabase.config";
import {SocialAuthButtons} from "@/components/wrappers/auth/social-buttons";
import {TooltipProvider} from "@/components/ui/tooltip";
import {Card, CardContent, CardHeader} from "@/components/ui/card";
import Link from "next/link";
import {Separator} from "@/components/ui/separator";
export default function SignInPage(props: {
searchParams: Promise<{ callbackUrl: string | undefined }>
}) {
export const metadata: Metadata = {
title: "Login",
};
const [urlParams, setUrlParams] = useState<URLSearchParams>();
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
setUrlParams(urlParams);
const error = urlParams.get("error");
console.log(urlParams.get("redirect"));
if (error?.includes("pending")) {
toast.error("Your account is not active.");
urlParams.delete("error");
window.history.replaceState({}, document.title, window.location.pathname + "?" + urlParams.toString());
}
}, []);
export default async function SignInPage() {
return (
<div className="mx-auto grid w-full gap-6">
<LoginForm/>
</div>
<TooltipProvider>
<Card className="w-full">
<CardHeader>
<div className="grid gap-2 text-center mb-2">
<h1 className="text-3xl font-bold">Login</h1>
<p className="text-balance text-muted-foreground">Fill your login informations</p>
</div>
</CardHeader>
<CardContent className="space-y-4">
<LoginForm />
{SUPPORTED_PROVIDERS.filter((p) => !p.isManual).length > 0 && (
<>
<div className="relative my-4 flex items-center justify-center overflow-hidden">
<Separator/>
<div className="px-2 text-center bg-card text-sm">OR</div>
<Separator/>
</div>
<SocialAuthButtons />
</>
)}
<div className="mt-4 text-center text-sm">
Don&apos;t have an account ?{" "}
<Link href="/register" className="underline">
Sign up
</Link>
</div>
</CardContent>
</Card>
</TooltipProvider>
)
}
}
+5
View File
@@ -1,5 +1,10 @@
import {PageParams} from "@/types/next";
import {RegisterForm} from "@/components/wrappers/auth/register/register-form/register-form";
import {Metadata} from "next";
export const metadata: Metadata = {
title: "Register",
};
export default async function RoutePage(props: PageParams<{}>) {
return (
+15
View File
@@ -0,0 +1,15 @@
import {PageParams} from "@/types/next";
import {Metadata} from "next";
import {ResetPasswordSection} from "@/components/wrappers/auth/reset-password/reset-password-section";
export const metadata: Metadata = {
title: "Reset Password",
};
export default async function RoutePage(props: PageParams<{}>) {
return (
<div className="mx-auto grid w-full gap-6">
<ResetPasswordSection/>
</div>
)
}
@@ -4,7 +4,7 @@ import {db} from "@/db";
import * as drizzleDb from "@/db";
import {PageParams} from "@/types/next";
import {Page} from "@/features/layout/page";
import {OrganizationManagement} from "@/components/wrappers/dashboard/admin/organization/organization-management";
import {OrganizationManagement} from "@/components/wrappers/dashboard/admin/organizations/organization/organization-management";
import {buildOrganizationWithMembers} from "@/utils/common";
import {isUUID} from "@/utils/text";
import {user} from "@/db/schema/02_user";
@@ -0,0 +1,35 @@
import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {db} from "@/db";
import {
AdminOrganizationAddModal
} from "@/components/wrappers/dashboard/admin/organizations/organization/admin-organization-add-modal";
import {AdminOrganizationList} from "@/components/wrappers/dashboard/admin/organizations/organization/admin-orgnization-list";
import {isNull} from "drizzle-orm";
export default async function RoutePage(props: PageParams<{}>) {
const organizations = await db.query.organization.findMany({
where: (fields) => isNull(fields.deletedAt),
with: {
members: true,
},
});
return (
<Page>
<PageHeader className="flex flex-col">
<div className="flex justify-between">
<PageTitle className="mb-3">Active organizations</PageTitle>
<PageActions>
<AdminOrganizationAddModal/>
</PageActions>
</div>
</PageHeader>
<PageContent className="flex flex-col gap-5">
<AdminOrganizationList organizations={organizations}/>
</PageContent>
</Page>
);
}
@@ -1,42 +0,0 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {AdminTabs} from "@/components/wrappers/dashboard/admin/admin-tabs";
import {db} from "@/db";
import {isNull} from "drizzle-orm";
export default async function RoutePage(props: PageParams<{}>) {
const users = await db.query.user.findMany({
where: (fields) => isNull(fields.deletedAt),
with: {
accounts: true
}
});
const organizations = await db.query.organization.findMany({
where: (fields) => isNull(fields.deletedAt),
with: {
members: true,
},
});
const settings = await db.query.setting.findFirst({
where: (fields, {eq}) => eq(fields.name, "system"),
});
return (
<Page>
<PageHeader>
<PageTitle>Administration Panel</PageTitle>
</PageHeader>
<PageContent>
<AdminTabs
organizations={organizations}
settings={settings!}
users={users}/>
</PageContent>
</Page>
);
}
@@ -0,0 +1,30 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {db} from "@/db";
import {SettingsEmailSection} from "@/components/wrappers/dashboard/admin/settings/email/settings-email-section";
import {notFound} from "next/navigation";
export default async function RoutePage(props: PageParams<{}>) {
const settings = await db.query.setting.findFirst({
where: (fields, {eq}) => eq(fields.name, "system"),
});
if (!settings) {
notFound()
}
return (
<Page>
<PageHeader className="flex flex-col">
<div className="flex justify-between">
<PageTitle className="mb-3">System email</PageTitle>
</div>
</PageHeader>
<PageContent className="flex flex-col gap-5">
<SettingsEmailSection settings={settings}/>
</PageContent>
</Page>
);
}
@@ -0,0 +1,29 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {SettingsStorageSection} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage-section";
import {db} from "@/db";
import {notFound} from "next/navigation";
export default async function RoutePage(props: PageParams<{}>) {
const settings = await db.query.setting.findFirst({
where: (fields, {eq}) => eq(fields.name, "system"),
});
if (!settings) {
notFound()
}
return (
<Page>
<PageHeader className="flex flex-col">
<div className="flex justify-between">
<PageTitle className="mb-3">System storage</PageTitle>
</div>
</PageHeader>
<PageContent className="flex flex-col gap-5">
<SettingsStorageSection settings={settings}/>
</PageContent>
</Page>
);
}
@@ -0,0 +1,32 @@
import {PageParams} from "@/types/next";
import {Page, 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";
export default async function RoutePage(props: PageParams<{}>) {
const users = await db.query.user.findMany({
where: (fields) => isNull(fields.deletedAt),
with: {
accounts: true
}
});
return (
<Page>
<PageHeader className="flex flex-col">
<div className="flex justify-between">
<PageTitle className="mb-3">Active users</PageTitle>
</div>
</PageHeader>
<PageContent className="flex flex-col gap-5">
<DataTable
enableSelect={false}
columns={usersColumnsAdmin}
data={users}/>
</PageContent>
</Page>
);
}
@@ -49,10 +49,12 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"}/>
</PageActions>
</div>
<PageDescription className="mt-5 sm:mt-0">{agent.description}</PageDescription>
<PageContent className="flex flex-col w-full h-full">
<div className="flex flex-col sm:flex-row sm:justify-between gap-8 mb-6">
{agent.description && (
<PageDescription className="mt-5 sm:mt-0">{agent.description}</PageDescription>
)}
<PageContent className="flex flex-col w-full h-full justify-between gap-6">
<div className="flex flex-col sm:flex-row sm:justify-between gap-6 ">
<Card className="w-full sm:w-auto flex-1">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Databases</CardTitle>
@@ -76,7 +78,7 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
</Card>
</div>
<Card className="w-full sm:w-auto flex-1 mb-4">
<Card className="w-full sm:w-auto flex-1 ">
<CardHeader className="font-bold text-xl">
Edge Key
</CardHeader>
@@ -86,7 +88,8 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
/>
</CardContent>
</Card>
<CardsWithPagination cardsPerPage={2} data={agent.databases} cardItem={DatabaseCard}/>
<CardsWithPagination cardsPerPage={4} numberOfColumns={2} data={agent.databases}
cardItem={DatabaseCard}/>
</PageContent>
</Page>
)
@@ -1,6 +1,11 @@
import { PageParams } from "@/types/next";
import { Page, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
import { AgentForm } from "@/components/wrappers/dashboard/agent/agent-form/agent-form";
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {AgentForm} from "@/components/wrappers/dashboard/agent/agent-form/agent-form";
import {Metadata} from "next";
export const metadata: Metadata = {
title: "Create Agent",
};
export default async function RoutePage(props: PageParams<{}>) {
return (
@@ -9,7 +14,7 @@ export default async function RoutePage(props: PageParams<{}>) {
<PageTitle>Create new agent</PageTitle>
</PageHeader>
<PageContent>
<AgentForm />
<AgentForm/>
</PageContent>
</Page>
);
@@ -1,25 +1,31 @@
import { PageParams } from "@/types/next";
import { AgentCard } from "@/components/wrappers/dashboard/agent/agent-card/agent-card";
import { CardsWithPagination } from "@/components/wrappers/common/cards-with-pagination";
import { Button } from "@/components/ui/button";
import {PageParams} from "@/types/next";
import {AgentCard} from "@/components/wrappers/dashboard/agent/agent-card/agent-card";
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
import {Button} from "@/components/ui/button";
import Link from "next/link";
import { Page, PageActions, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
import { notFound } from "next/navigation";
import { db } from "@/db";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {notFound} from "next/navigation";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {and, eq, not} from "drizzle-orm";
import {Plus} from "lucide-react";
import {cn} from "@/lib/utils";
import {eq, not} from "drizzle-orm";
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
// export const dynamic = "force-dynamic";
import {Metadata} from "next";
export const metadata: Metadata = {
title: "Agents",
};
export default async function RoutePage(props: PageParams<{}>) {
const agents = await db.query.agent.findMany({
where: not(eq(drizzleDb.schemas.agent.isArchived, true))
where: not(eq(drizzleDb.schemas.agent.isArchived, true)),
with: {
databases: true
}
});
console.log(agents);
if (!agents) {
notFound();
@@ -39,7 +45,7 @@ export default async function RoutePage(props: PageParams<{}>) {
</PageHeader>
<PageContent>
{agents.length > 0 ? (
<CardsWithPagination data={agents} cardItem={AgentCard} cardsPerPage={4} numberOfColumns={1} />
<CardsWithPagination data={agents} cardItem={AgentCard} cardsPerPage={4} numberOfColumns={1}/>
) : (
<EmptyStatePlaceholder
url={"/dashboard/agents/new"}
@@ -0,0 +1,46 @@
import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Metadata} from "next";
import {
NotificationChannelsSection
} from "@/components/wrappers/dashboard/admin/notifications/channels/notification-channels-section";
import {db} from "@/db";
import {notificationChannel, NotificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {NotifierAddEditModal} from "@/components/wrappers/dashboard/common/notifier/notifier-add-edit-modal";
import {desc, isNull} from "drizzle-orm";
export const metadata: Metadata = {
title: "Notification Channels",
};
export default async function RoutePage(props: PageParams<{}>) {
const notificationChannels = await db.query.notificationChannel.findMany({
with: {
organizations: true
},
orderBy: desc(notificationChannel.createdAt)
}) as NotificationChannelWith[]
const organizations = await db.query.organization.findMany({
where: (fields) => isNull(fields.deletedAt),
with: {
members: true,
},
});
return (
<Page>
<PageHeader>
<PageTitle>Notification channels</PageTitle>
<PageActions>
<NotifierAddEditModal adminView={false}/>
</PageActions>
</PageHeader>
<PageContent>
<NotificationChannelsSection organizations={organizations} notificationChannels={notificationChannels}/>
</PageContent>
</Page>
);
}
@@ -0,0 +1,30 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Metadata} from "next";
import {NotificationLogsList} from "@/components/wrappers/dashboard/admin/notifications/logs/notification-logs-list";
import {notFound} from "next/navigation";
import {getNotificationHistory} from "@/db/services/notification-log";
export const metadata: Metadata = {
title: "Activity logs",
};
export default async function RoutePage(props: PageParams<{}>) {
const notificationLogs = await getNotificationHistory()
if (!notificationLogs) {
notFound();
}
return (
<Page>
<PageHeader>
<PageTitle>Activity logs</PageTitle>
</PageHeader>
<PageContent>
<NotificationLogsList notificationLogs={notificationLogs} />
</PageContent>
</Page>
);
}
@@ -1,7 +1,7 @@
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 {RestoreForm} from "@/components/wrappers/dashboard/database/restore-form";
import {currentUser} from "@/lib/auth/current-user";
import {db} from "@/db";
@@ -34,7 +34,7 @@ export default async function RoutePage(
<PageTitle>Restore {dbToRestore.name}</PageTitle>
</PageHeader>
<PageContent>
<RestoreForm databaseToRestore={dbToRestore} databases={dbsOfSameType} backups={successfulBackups}/>
{/*<RestoreForm databaseToRestore={dbToRestore} databases={dbsOfSameType} backups={successfulBackups}/>*/}
</PageContent>
</Page>
);
@@ -1,12 +1,10 @@
import {PageParams} from "@/types/next";
import {notFound, redirect} from "next/navigation";
import {Page, PageActions, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
import {Page, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
import {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
import {DatabaseTabs} from "@/components/wrappers/dashboard/projects/database/database-tabs";
import {DatabaseKpi} from "@/components/wrappers/dashboard/projects/database/database-kpi";
import {EditButton} from "@/components/wrappers/dashboard/database/edit-button/edit-button";
import {CronButton} from "@/components/wrappers/dashboard/database/cron-button/cron-button";
import {db} from "@/db";
import {eq, and, inArray} from "drizzle-orm";
import * as drizzleDb from "@/db";
@@ -14,6 +12,8 @@ import {getOrganizationProjectDatabases} from "@/lib/services";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet";
import {capitalizeFirstLetter} from "@/utils/text";
import {AlertPolicyModal} from "@/components/wrappers/dashboard/database/alert-policy/alert-policy-modal";
import {getOrganizationChannels} from "@/db/services/notification-channel";
export default async function RoutePage(props: PageParams<{
projectId: string;
@@ -37,7 +37,8 @@ export default async function RoutePage(props: PageParams<{
where: and(inArray(drizzleDb.schemas.backup.id, databasesProject.ids ?? []), eq(drizzleDb.schemas.database.id, databaseId), eq(drizzleDb.schemas.database.projectId, projectId)),
with: {
project: true,
retentionPolicy: true
retentionPolicy: true,
alertPolicies: true
}
});
@@ -82,25 +83,30 @@ export default async function RoutePage(props: PageParams<{
notFound();
}
const organizationChannels = await getOrganizationChannels(organization.id);
const activeOrganizationChannels = organizationChannels.filter(channel => channel.enabled);
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
const isMember = activeMember?.role === "member";
return (
<Page>
<div className="justify-between gap-2 sm:flex">
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full">
<div className=" w-full md:w-fit">
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="min-w-full md:min-w-fit ">
{capitalizeFirstLetter(dbItem.name)}
</div>
{!isMember && (
<div className="flex items-center gap-2 md:justify-between w-full">
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
{/* Do not delete*/}
{/*<EditButton/>*/}
<RetentionPolicySheet database={dbItem}/>
<CronButton database={dbItem}/>
<AlertPolicyModal database={dbItem} notificationChannels={activeOrganizationChannels}
organizationId={organization.id}/>
</div>
<div className="flex items-center gap-2">
<BackupButton disable={isAlreadyBackup} databaseId={databaseId}/>
@@ -108,15 +114,18 @@ export default async function RoutePage(props: PageParams<{
</div>
)}
</PageTitle>
</div>
{dbItem.description && (
<PageDescription className="mt-5 sm:mt-0">{dbItem.description}</PageDescription>
)}
<PageContent className="flex flex-col w-full h-full">
<DatabaseKpi successRate={successRate} database={dbItem} availableBackups={availableBackups}
<DatabaseKpi successRate={successRate} database={dbItem} availableBackups={availableBackups}
totalBackups={totalBackups}/>
<DatabaseTabs activeMember={activeMember} settings={settings} database={dbItem} isAlreadyRestore={isAlreadyRestore}
<DatabaseTabs activeMember={activeMember} settings={settings} database={dbItem}
isAlreadyRestore={isAlreadyRestore}
backups={backups}
restorations={restorations}/>
</PageContent>
@@ -9,7 +9,11 @@ import {db} from "@/db";
import {notFound} from "next/navigation";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
import {Metadata} from "next";
export const metadata: Metadata = {
title: "Projects",
};
export default async function RoutePage(props: PageParams<{}>) {
@@ -7,40 +7,51 @@ import {
DeleteOrganizationButton
} from "@/components/wrappers/dashboard/organization/delete-organization/delete-organization-button";
import {EditButtonSettings} from "@/components/wrappers/dashboard/settings/edit-button-settings/edit-button-settings";
import {
SettingsOrganizationMembersTable
} from "@/components/wrappers/dashboard/settings/settings-organization-members-table";
import {Metadata} from "next";
import {OrganizationTabs} from "@/components/wrappers/dashboard/organization/tabs/organization-tabs";
import {getOrganizationChannels} from "@/db/services/notification-channel";
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
export const metadata: Metadata = {
title: "Settings",
};
export default async function RoutePage(props: PageParams<{ slug: string }>) {
const organization = await getOrganization({});
const user = await currentUser();
const activeMember = await getActiveMember()
if (!organization) {
if (!organization || !activeMember) {
notFound();
}
const isMember = activeMember?.role === "member";
const isOwner = activeMember?.role === "owner";
const notificationChannels = await getOrganizationChannels(organization.id)
const permissions = computeOrganizationPermissions(activeMember);
return (
<Page>
<PageHeader>
<PageTitle className="flex items-center">
Organization settings
{!isMember && organization.slug !== "default" && (
{permissions.canManageSettings && organization.slug !== "default" && (
<EditButtonSettings/>
)}
</PageTitle>
<PageActions>
{isOwner && organization.slug !== "default" && (
{permissions.canManageDangerZone && organization.slug !== "default" && (
<DeleteOrganizationButton organizationSlug={organization.slug}/>
)}
</PageActions>
</PageHeader>
<PageContent>
<SettingsOrganizationMembersTable organization={organization}/>
<OrganizationTabs
activeMember={activeMember}
organization={organization}
notificationChannels={notificationChannels}
/>
</PageContent>
</Page>
)
@@ -9,7 +9,11 @@ import {and, asc, count, eq, inArray} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {getOrganization} from "@/lib/auth/auth";
import {Building2, DatabaseBackup, Folder, RefreshCcw} from "lucide-react";
import {Metadata} from "next";
export const metadata: Metadata = {
title: "Statistics",
};
export default async function RoutePage(props: PageParams<{}>) {
const organization = await getOrganization({});
+5
View File
@@ -8,6 +8,11 @@ import {db} from "@/db";
import {asc, inArray} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {listOrganizations} from "@/lib/auth/auth";
import {Metadata} from "next";
export const metadata: Metadata = {
title: "Home",
};
export default async function RoutePage(props: PageParams<{}>) {
-62
View File
@@ -1,62 +0,0 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageTitle} from "@/features/layout/page";
import {notFound} from "next/navigation";
import {UserForm} from "@/components/wrappers/dashboard/profile/user-form/user-form";
import {Badge} from "@/components/ui/badge";
import {ButtonDeleteAccount} from "@/components/wrappers/dashboard/profile/button-delete-account/button-delete-account";
import {AvatarWithUpload} from "@/components/wrappers/dashboard/profile/avatar/avatar-with-upload";
import {currentUser} from "@/lib/auth/current-user";
import {getAccounts, getSessions} from "@/lib/auth/auth";
export default async function RoutePage(props: PageParams<{}>) {
const user = await currentUser();
if (!user) {
return notFound();
}
if (user.role !== "user" && user.role !== "admin" && user.role !== "superadmin") {
return notFound();
}
const sessions = await getSessions();
const accounts = await getAccounts();
return (
<Page>
<div className="justify-between gap-2 sm:flex">
<PageTitle className="flex items-center">
<AvatarWithUpload
user={{
...user,
image: user.image ?? null,
role: user.role ?? null,
banned: user.banned ?? null,
banReason: user.banReason ?? null,
banExpires: user.banExpires ?? null,
deletedAt: user.deletedAt ? new Date(user.deletedAt) : null,
}}
/>
{user.name}
<Badge className="ml-3 hidden lg:block">{user.role}</Badge>
</PageTitle>
{/*<PageActions className="mt-2 hidden sm:block">*/}
{/* <ButtonDeleteAccount text="Delete my account"/>*/}
{/*</PageActions>*/}
</div>
<PageContent >
<UserForm
userId={user.id}
sessions={sessions} accounts={accounts}
defaultValues={{
name: user.name,
email: user.email,
role: user.role ?? undefined,
}}
/>
{/*<div className="mt-4 sm:hidden ">*/}
{/* <ButtonDeleteAccount text="Delete my account"/>*/}
{/*</div>*/}
</PageContent>
</Page>
);
}
+7 -2
View File
@@ -1,8 +1,11 @@
import fs from "node:fs";
import forge from "node-forge";
import {EventPayload} from "@/features/notifications/types";
import {dispatchNotification} from "@/features/notifications/dispatch";
import {Database, DatabaseWith} from "@/db/schema/07_database";
export async function decryptedDump(file: File, aesKeyHex: string, ivHex: string, fileExtension: string ): Promise<File> {
export async function decryptedDump(file: File, aesKeyHex: string, ivHex: string, fileExtension: string): Promise<File> {
const privateKeyPem = fs.readFileSync("private/keys/server_private.pem", "utf8");
const privateKey = forge.pki.privateKeyFromPem(privateKeyPem);
@@ -48,4 +51,6 @@ export function getFileExtension(dbType: string) {
default:
return ".dump";
}
}
}
+8 -1
View File
@@ -10,6 +10,7 @@ import {and, eq} from "drizzle-orm";
import {env} from "@/env.mjs";
import {withUpdatedAt} from "@/db/utils";
import {decryptedDump, getFileExtension} from "./helpers";
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
export async function POST(
request: Request,
@@ -54,7 +55,8 @@ export async function POST(
const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId),
with: {
project: true
project: true,
alertPolicies: true
}
});
@@ -153,6 +155,8 @@ export async function POST(
eventEmitter.emit('modification', {update: true});
await sendNotificationsBackupRestore(database, "success_backup");
return NextResponse.json(
{
message: "Backup successfully uploaded",
@@ -168,6 +172,9 @@ export async function POST(
eventEmitter.emit('modification', {update: true});
await sendNotificationsBackupRestore(database, "error_backup");
return NextResponse.json(
{
message: "Backup successfully updated with status failed",
+10 -3
View File
@@ -4,6 +4,7 @@ import {eventEmitter} from "../../../events/route";
import * as drizzleDb from "@/db";
import {db} from "@/db";
import {and, eq} from "drizzle-orm";
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
export type BodyResultRestore = {
generatedId: string
@@ -40,8 +41,10 @@ export async function POST(
}
const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.agentDatabaseId, body.generatedId)
where: eq(drizzleDb.schemas.database.agentDatabaseId, body.generatedId),
with: {
alertPolicies: true
}
})
if (!database) {
@@ -56,11 +59,15 @@ export async function POST(
return NextResponse.json({error: "Unable to fin the corresponding restoration"}, {status: 404})
}
await db
.update(drizzleDb.schemas.restoration)
.set({ status: body.status as RestorationStatus })
.set({status: body.status as RestorationStatus})
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
await sendNotificationsBackupRestore(database, body.status == "error" ? "error_restore" : "success_restore");
const response = {
message: true,
details: "Restoration successfully updated"
+6 -3
View File
@@ -7,7 +7,7 @@ import {Database} from "@/db/schema/07_database";
import * as drizzleDb from "@/db";
import {db as dbClient} from "@/db";
import {and, eq} from "drizzle-orm";
import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types";
import {EDbmsSchema} from "@/db/schema/types";
import {ServerActionResult} from "@/types/action-type";
import {SafeActionResult} from "next-safe-action";
import {ZodString} from "zod";
@@ -64,10 +64,13 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
}
} else {
const [databaseUpdated] = await dbClient
.update(drizzleDb.schemas.database)
.set({lastContact: lastContact})
.set(withUpdatedAt({
name: db.name,
agentId: agent.id,
lastContact: lastContact
}))
.where(eq(drizzleDb.schemas.database.id, existingDatabase.id))
.returning();
+7 -2
View File
@@ -7,6 +7,7 @@ import {db} from "@/db";
import {EDbmsSchema} from "@/db/schema/types";
import {eq} from "drizzle-orm";
import {isUuidv4} from "@/utils/verify-uuid";
import {withUpdatedAt} from "@/db/utils";
export type databaseAgent = {
name: string,
@@ -15,12 +16,13 @@ export type databaseAgent = {
}
export type Body = {
version: string,
databases: databaseAgent[]
}
// Function to test the get file url presigned local
export async function GET(request: Request) {
const url = await getFileUrlPresignedLocal({fileName:"d4a7fa35-2506-4d01-a612-a8ef2e2cc1c5.dump"})
const url = await getFileUrlPresignedLocal({fileName: "d4a7fa35-2506-4d01-a612-a8ef2e2cc1c5.dump"})
return Response.json({
message: url
})
@@ -60,7 +62,10 @@ export async function POST(
await db
.update(drizzleDb.schemas.agent)
.set({lastContact: lastContact})
.set(withUpdatedAt({
version: body.version,
lastContact: lastContact
}))
.where(eq(drizzleDb.schemas.agent.id, agentId));
eventEmitter.emit('modification', {update: true});
+9
View File
@@ -0,0 +1,9 @@
import {NextResponse} from "next/server";
export async function GET() {
return NextResponse.json({
PROJECT_URL: process.env.PROJECT_URL,
PROJECT_NAME: process.env.PROJECT_NAME,
PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION,
});
}
+7 -2
View File
@@ -6,9 +6,14 @@ import {cn} from "@/lib/utils";
import {ConsoleSilencer} from "@/components/wrappers/common/console-silencer";
import {inter} from "@/fonts/fonts";
const title = process.env.PROJECT_NAME ?? "App Title";
export const metadata: Metadata = {
title: process.env.NEXT_PUBLIC_PROJECT_NAME ?? "Portabase",
description: process.env.NEXT_PUBLIC_PROJECT_DESCRIPTION ?? undefined,
title: {
default: title,
template: `%s - ${title}`
},
description: process.env.PROJECT_DESCRIPTION ?? undefined,
};
export default function RootLayout({
+2
View File
@@ -1,3 +1,4 @@
import BackButton from "@/components/wrappers/common/button/back-button";
export default async function NotFound() {
return(
@@ -7,6 +8,7 @@ export default async function NotFound() {
<h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">Not found</h1>
<p className="leading-7 [&:not(:first-child)]:mt-6">The content you are trying to view is not available.</p>
</div>
<BackButton>Go home</BackButton>
</div>
</div>
)
+5 -1
View File
@@ -10,11 +10,15 @@
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "radix",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {
"@reui": "https://reui.io/r/{name}.json"
}
}
}
+7 -19
View File
@@ -3,13 +3,15 @@ name: portabase-prod
services:
app:
# build:
# context: .
# dockerfile: docker/dockerfile/Dockerfile
# target: prod
image: solucetechnologies/portabase:1.1.3-rc.2
# build:
# context: .
# dockerfile: docker/dockerfile/Dockerfile
# target: prod
image: solucetechnologies/portabase:1.1.4-rc.2
ports:
- '8887:80'
environment:
TZ: "Europe/Paris"
env_file:
- .env
depends_on:
@@ -33,19 +35,5 @@ services:
timeout: 5s
retries: 5
# s3:
# image: docker.io/bitnami/minio:latest
# ports:
# - '9000:9000'
# - '9001:9001'
# volumes:
# - minio_data:/data
# environment:
# - MINIO_ROOT_USER=${S3_ACCESS_KEY}
# - MINIO_ROOT_PASSWORD=${S3_SECRET_KEY}
# - MINIO_DEFAULT_BUCKETS=${S3_BUCKET_NAME}
volumes:
postgres-data:
# minio_data:
+16 -16
View File
@@ -1,21 +1,21 @@
name: portabase-dev
services:
db:
image: postgres:16-alpine
ports:
- "5433:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=devdb
- POSTGRES_USER=devuser
- POSTGRES_PASSWORD=changeme
healthcheck:
test: ["CMD-SHELL", "pg_isready -U devuser -d devdb"]
interval: 10s
timeout: 5s
retries: 5
db:
image: postgres:16-alpine
ports:
- "5433:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=devdb
- POSTGRES_USER=devuser
- POSTGRES_PASSWORD=changeme
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U devuser -d devdb" ]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres-data:
postgres-data:
+2 -3
View File
@@ -3,6 +3,7 @@ FROM node:22-alpine AS base
ENV YARN_VERSION=4.9.1
RUN apk add --update --no-cache \
tzdata \
build-base \
libc6-compat \
openssl \
@@ -94,9 +95,6 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --chown=nextjs:nodejs src/db ./src/db
USER root
COPY ./docker/entrypoints/app-prod-entrypoint.sh /app/app-prod-entrypoint.sh
@@ -112,3 +110,4 @@ USER nextjs
ENTRYPOINT ["sh","/app/app-prod-entrypoint.sh"]
+10 -1
View File
@@ -1,5 +1,14 @@
#!/bin/bash
if [ -n "$TZ" ]; then
echo "[INFO] Application timezone set to $TZ (environment only)"
export TZ="$TZ"
else
echo "[WARN] No TZ provided, using default container timezone"
fi
node server.js
exec "$@"
exec "$@"
-6
View File
@@ -1,6 +0,0 @@
import { Button as ReactEmailButton } from "@react-email/components";
import { ComponentPropsWithoutRef } from "react";
export const Button = (props: ComponentPropsWithoutRef<typeof ReactEmailButton>) => {
return <ReactEmailButton className="block w-52 rounded bg-blue-600 py-3.5 text-center text-sm font-normal text-white no-underline " {...props} />;
};
-13
View File
@@ -1,13 +0,0 @@
import { Button } from "@react-email/button";
import { Html } from "@react-email/html";
import * as React from "react";
export default function HelloEmail() {
return (
<Html>
<Button href="https://example.com" style={{ background: "#000", color: "#fff" }}>
Hello,
</Button>
</Html>
);
}
-16
View File
@@ -1,16 +0,0 @@
import { Text } from "@react-email/components";
import * as React from "react";
import EmailLayout from "./EmailLayout";
import { Button } from "@react-email/button";
export const TestEmailSettings = () => {
return (
<EmailLayout preview="Email Setup">
<Text className="text-base font-light leading-8 text-green-800 ">Hi, your email settings are setup !</Text>
<Text className="text-base font-light leading-8 text-green-800 ">Best regard,</Text>{" "}
<Text className="text-base font-light leading-8 text-green-800 ">Portabase</Text>
</EmailLayout>
);
};
export default TestEmailSettings;
-6
View File
@@ -1,6 +0,0 @@
import { Text as ReactEmailText } from "@react-email/components";
import { ComponentPropsWithoutRef } from "react";
export const Text = (props: ComponentPropsWithoutRef<typeof ReactEmailText>) => {
return <ReactEmailText className="text-base font-light leading-8 text-green-800 " {...props} />;
};
-2
View File
@@ -3,9 +3,7 @@ import nextVitals from 'eslint-config-next/core-web-vitals'
const eslintConfig = defineConfig([
...nextVitals,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
'.next/**',
'out/**',
'build/**',
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+3 -1
View File
@@ -42,7 +42,9 @@ const nextConfig: NextConfig = {
ignoreBuildErrors: true,
},
experimental: {
turbopackFileSystemCacheForDev: true,
serverActions: {
bodySizeLimit: "50mb",
},
},
async headers() {
return [
+10 -6
View File
@@ -1,19 +1,18 @@
{
"name": "portabase",
"version": "1.1.3",
"version": "1.1.5",
"private": true,
"scripts": {
"dev": "next dev --turbopack -p 8887",
"build": "next build --experimental-build-mode compile",
"start": "next start",
"lint": "next lint",
"email": "email dev",
"email": "email dev --dir ./src/components/emails",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:drop": "drizzle-kit drop",
"auth:generate": "npx @better-auth/cli generate --config ./src/lib/auth/auth.ts"
},
"type": "module",
"dependencies": {
"@hookform/resolvers": "^5.0.1",
"@radix-ui/react-accordion": "^1.2.10",
@@ -53,7 +52,7 @@
"@zenstackhq/runtime": "2.14.2",
"argon2": "^0.43.0",
"bcrypt": "^6.0.0",
"better-auth": "1.3.1",
"better-auth": "1.4.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -64,9 +63,10 @@
"drizzle-zod": "^0.7.1",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.510.0",
"lucide-react": "^0.553.0",
"minio": "^8.0.5",
"next": "16.0.0",
"motion": "^12.23.24",
"next": "16.0.10",
"next-safe-action": "^7.10.8",
"next-themes": "^0.4.6",
"node-cron": "^4.2.1",
@@ -80,8 +80,10 @@
"react-dropzone": "^14.3.8",
"react-email": "^4.0.13",
"react-hook-form": "^7.56.3",
"react-qr-code": "^2.0.18",
"react-resizable-panels": "^3.0.2",
"react-twc": "^1.4.2",
"react-use-measure": "^2.1.7",
"recharts": "^2.15.3",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
@@ -94,6 +96,7 @@
},
"devDependencies": {
"@iconify/react": "^6.0.0",
"@react-email/preview-server": "4.3.2",
"@tailwindcss/postcss": "^4.1.7",
"@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/node": "^22.15.18",
@@ -103,6 +106,7 @@
"@types/react-dom": "^19.1.5",
"@zenstackhq/openapi": "^2.14.2",
"@zenstackhq/tanstack-query": "^2.14.2",
"baseline-browser-mapping": "^2.8.32",
"drizzle-kit": "^0.31.1",
"eslint": "^9.39.0",
"eslint-config-next": "^16.0.1",
+47
View File
@@ -1,3 +1,16 @@
import {Chrome, KeyRound, LucideIcon} from "lucide-react";
export interface AuthProviderConfig {
id: "google" | "github" | "credential";
icon: LucideIcon;
isManual?: boolean;
credentials?: {
clientId: string;
clientSecret: string;
};
}
export const PORTABASE_DEFAULT_SETTINGS = {
SECURITY: {
@@ -43,6 +56,8 @@ export const PORTABASE_DEFAULT_SETTINGS = {
"'self'",
"https://api.iconify.design",
"https://code.iconify.design",
"https://api.github.com",
],
OBJECT_SRC: ["'none'"],
BASE_URI: ["'self'"],
@@ -60,3 +75,35 @@ export const PORTABASE_DEFAULT_SETTINGS = {
},
},
};
export const SUPPORTED_PROVIDERS: AuthProviderConfig[] = [
{
id: "google",
icon: Chrome,
credentials: {
clientId: process.env.GOOGLE_CLIENT_ID || "",
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
},
},
/*
{
id: "github",
name: "GitHub",
icon: Github,
description: "Connexion via GitHub",
credentials: {
clientId: process.env.GITHUB_CLIENT_ID || "",
clientSecret: process.env.GITHUB_CLIENT_SECRET || "",
},
},*/
{
id: "credential",
icon: KeyRound,
isManual: true,
credentials: {
clientId: "",
clientSecret: "",
},
},
];
+10 -15
View File
@@ -1,9 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { loggingMiddleware } from "@/middleware/loggingMiddleware";
import { errorHandler } from "@/middleware/errorHandler";
import { auth } from "@/lib/auth/auth";
import { headers } from "next/headers";
import { signOut } from "@/lib/auth/auth-client";
import {NextRequest, NextResponse} from "next/server";
import {loggingMiddleware} from "@/middleware/loggingMiddleware";
import {errorHandler} from "@/middleware/errorHandler";
import {auth} from "@/lib/auth/auth";
import {headers} from "next/headers";
export async function proxy(request: NextRequest) {
const url = request.nextUrl.clone();
@@ -13,25 +12,20 @@ export async function proxy(request: NextRequest) {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.redirect(new URL(`/login?redirect=${redirectUrl}`, request.url));
}
if (session.user.banned) {
signOut();
await auth.api.signOut({headers: await headers()});
return NextResponse.redirect(new URL("/login?error=banned", request.url));
}
if (session.user.role === "pending") {
signOut();
await auth.api.signOut({headers: await headers()});
return NextResponse.redirect(new URL(`/login?error=pending?redirect=${redirectUrl}`, request.url));
}
if (url.pathname === "/dashboard") {
return NextResponse.redirect(new URL(`/dashboard/home`, request.url));
}
return NextResponse.next();
}
@@ -42,9 +36,9 @@ export async function proxy(request: NextRequest) {
if (url.pathname.startsWith("/api")) {
const routeExists = checkRouteExists(url.pathname);
if (!routeExists) {
return new NextResponse(JSON.stringify({ message: "This API route does not exist.", status: 404 }), {
return new NextResponse(JSON.stringify({message: "This API route does not exist.", status: 404}), {
status: 404,
headers: { "Content-Type": "application/json" },
headers: {"Content-Type": "application/json"},
});
}
}
@@ -64,6 +58,7 @@ function checkRouteExists(pathname: string) {
/^\/api\/images\/[^/]+\/?$/,
/^\/api\/events\/?$/,
/^\/api\/init\/?$/,
/^\/api\/config\/?$/,
];
return routePatterns.some((pattern) => pattern.test(pathname));
}
@@ -13,7 +13,7 @@ export const EmailLayout = ({ children, preview }: PropsWithChildren<{ preview?:
{preview ? <Preview>{preview}</Preview> : <Preview>Please check your mails</Preview>}
<Body className="bg-gray-100 py-4" style={{ fontFamily: "Arial, sans-serif" }}>
<Container className="bg-white border border-gray-200 p-12">
<Img src={`${baseUrl}/logo-title-black.png`} width="200" height="auto" alt="Logo" />
<Img src={`${baseUrl}/images/logo-black.png`} width="200" height="auto" alt="Logo" />
<Section>{children}</Section>
</Container>
</Body>
@@ -0,0 +1,97 @@
// import * as React from "react";
// import EmailLayout from "./email-layout";
// import {Text, Section, Button} from "@react-email/components";
// import type {EventPayload} from "@/features/notifications/types";
//
// export interface EmailNotificationProps {
// payload: EventPayload
// }
//
// export const EmailNotification = ({payload}: EmailNotificationProps) => {
// return (
// <EmailLayout preview={payload.title}>
// <Text className="text-base text-green-800 font-bold ">${payload.title}</Text>
// <Text className="text-base text-green-800 font-bold "><strong>Level:</strong> ${payload.level}</Text>
//
//
// <Text className="text-base font-light text-green-800 ">
// ${payload.message.replace(/\n/g, '<br>')}
// </Text>{" "}
//
//
// <Section className="mt-[32px] mb-[32px] text-center">
//
// </Section>
//
// {payload.data && (
// <Section className="mt-[16px] text-sm text-gray-700">
// <pre>{JSON.stringify(payload.data, null, 2)}</pre>
// </Section>
// )}
//
// <Text className="text-base font-light text-green-800 ">Regards,<br/>Portabase</Text>
// </EmailLayout>
// );
// };
//
// export default EmailNotification;
// const html = `
// <h2>${payload.title}</h2>
// <p><strong>Level:</strong> ${payload.level}</p>
// <p>${payload.message.replace(/\n/g, '<br>')}</p>
// ${payload.data ? `<pre>${JSON.stringify(payload.data, null, 2)}</pre>` : ''}
// `;
//
import * as React from "react";
import EmailLayout from "./email-layout";
import {Text, Section, Button} from "@react-email/components";
import type {EventPayload} from "@/features/notifications/types";
export interface EmailNotificationProps {
payload: EventPayload;
}
export const EmailNotification = ({payload}: EmailNotificationProps) => {
return (
<EmailLayout preview={payload.message}>
<Section>
<Text className="text-xl font-bold ">
{payload.title}
</Text>
<Text className=" font-semibold ">
Level: {payload.level}
</Text>
</Section>
{payload.message && (
<Section className="mb-2">
<Text
className="text-base font-light"
dangerouslySetInnerHTML={{__html: payload.message.replace(/\n/g, "<br>")}}
/>
</Section>
)}
{payload.data && (
<Section className="mb-6 p-4 bg-gray-100 rounded text-sm text-gray-700">
<Text>
<pre>{JSON.stringify(payload.data, null, 2)}</pre>
</Text>
</Section>
)}
<Section className="mt-8">
<Text className="text-base font-light ">
Regards,
<br/>
Portabase
</Text>
</Section>
</EmailLayout>
);
};
export default EmailNotification;
@@ -0,0 +1,30 @@
import * as React from "react";
import EmailLayout from "./email-layout";
import {Text, Section, Button} from "@react-email/components";
export interface EmailResetPasswordProps {
url: string;
}
export const EmailResetPassword = ({url}: EmailResetPasswordProps) => {
return (
<EmailLayout preview="Email for password reset of your Portabase account">
<Text className="text-base font-bold ">Hello !</Text>
<Text className="text-base font-light ">You are receiving this email because we
received a password reset request for your account.</Text>{" "}
<Section className="mt-[32px] mb-[32px] text-center">
<Button
className="rounded bg-[#000000] px-5 py-3 text-center font-semibold text-[12px] text-white no-underline"
href={url}
>
Reset Password
</Button>
</Section>
<Text className="text-base font-light ">If you did not request a password reset, no
further action is required.</Text>
<Text className="text-base font-light ">Regards,<br/>Portabase</Text>
</EmailLayout>
);
};
export default EmailResetPassword;
@@ -0,0 +1,15 @@
import { Text } from "@react-email/components";
import * as React from "react";
import EmailLayout from "./email-layout";
export const EmailSettingsTest = () => {
return (
<EmailLayout preview="Email Setup">
<Text className="text-base font-light leading-8 ">Hi, your email settings are setup !</Text>
<Text className="text-base font-light leading-8 ">Best regard,</Text>{" "}
<Text className="text-base font-light leading-8 ">Portabase</Text>
</EmailLayout>
)
};
export default EmailSettingsTest;
+6
View File
@@ -0,0 +1,6 @@
import {Text as ReactEmailText} from "@react-email/components";
import {ComponentPropsWithoutRef} from "react";
export const EmailText = (props: ComponentPropsWithoutRef<typeof ReactEmailText>) => {
return <ReactEmailText className="text-base font-light leading-8 text-green-800 " {...props} />;
};
@@ -0,0 +1,51 @@
import {Heading, Text, Section, Button} from "@react-email/components";
import {getServerUrl} from "@/utils/get-server-url";
import EmailLayout from "@/components/emails/email-layout";
interface EmailCreateUserProps {
firstname?: string;
oldEmail?: string;
newEmail?: string;
url: string;
}
export const EmailVerification = ({firstname, oldEmail, newEmail, url: urlVerification}: EmailCreateUserProps) => {
const serverUrl = new URL(getServerUrl());
const url = new URL(urlVerification);
url.hostname = serverUrl.hostname;
url.port = serverUrl.port == "80" ? "" : serverUrl.port;
const newUrl = url.toString();
return (
<EmailLayout preview="Portabase email verification">
<Heading className="mx-0 my-[30px] p-0 text-center font-normal text-[24px] text-black">
Hello <strong>{firstname}</strong>,
</Heading>
<Text className="text-[14px] text-black leading-[24px]">
We received a request to change the email address associated with your account.
</Text>
<Text className="text-[14px] text-black leading-[24px]">
If you did not request this change, please ignore this email. Your current email address will remain
unchanged. If this seems suspicious, contact your administrator.
</Text>
{oldEmail && newEmail ? (
<Text className="text-[14px] text-black leading-[24px]">
<strong>Old email address:</strong> {oldEmail}
<br/>
<strong>New email address:</strong> {newEmail}
</Text>
) : null}
<Section className="mt-[32px] mb-[32px] text-center">
<Button
className="rounded bg-[#000000] px-5 py-3 text-center font-semibold text-[12px] text-white no-underline"
href={newUrl}>
Confirm email address
</Button>
</Section>
</EmailLayout>
);
};
export default EmailVerification;
+70 -69
View File
@@ -7,117 +7,118 @@ import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
"max-h-[90vh] overflow-y-auto",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
+296
View File
@@ -0,0 +1,296 @@
'use client';
import React, { useCallback, useEffect, useState } from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { Star } from 'lucide-react';
import { motion, useInView, type SpringOptions, type UseInViewOptions } from 'motion/react';
import { cn } from '@/lib/utils';
const githubButtonVariants = cva(
'cursor-pointer relative overflow-hidden will-change-transform backface-visibility-hidden transform-gpu transition-transform duration-200 ease-out hover:scale-105 group whitespace-nowrap focus-visible:outline-hidden inline-flex items-center justify-center whitespace-nowrap font-medium ring-offset-background disabled:pointer-events-none disabled:opacity-60 [&_svg]:shrink-0',
{
variants: {
variant: {
default:
'bg-zinc-950 hover:bg-zinc-900 text-white border-gray-700 dark:bg-zinc-50 dark:border-gray-300 dark:text-zinc-950 dark:hover:bg-zinc-50',
outline: 'bg-background text-accent-foreground border border-input hover:bg-accent',
},
size: {
default: 'h-8.5 rounded-md px-3 gap-2 text-[0.8125rem] leading-none [&_svg]:size-4 gap-2',
sm: 'h-7 rounded-md px-2.5 gap-1.5 text-xs leading-none [&_svg]:size-3.5 gap-1.5',
lg: 'h-10 rounded-md px-4 gap-2.5 text-sm leading-none [&_svg]:size-5 gap-2.5',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
interface GithubButtonProps extends React.ComponentProps<'button'>, VariantProps<typeof githubButtonVariants> {
/** Whether to round stars */
roundStars?: boolean;
/** Whether to show Github icon */
fixedWidth?: boolean;
/** Initial number of stars */
initialStars?: number;
/** Class for stars */
starsClass?: string;
/** Target number of stars to animate to */
targetStars?: number;
/** Animation duration in seconds */
animationDuration?: number;
/** Animation delay in seconds */
animationDelay?: number;
/** Whether to start animation automatically */
autoAnimate?: boolean;
/** Callback when animation completes */
onAnimationComplete?: () => void;
/** Whether to show Github icon */
showGithubIcon?: boolean;
/** Whether to show star icon */
showStarIcon?: boolean;
/** Whether to show separator */
separator?: boolean;
/** Whether stars should be filled */
filled?: boolean;
/** Repository URL for actual Github integration */
repoUrl?: string;
/** Button text label */
label?: string;
/** Use in-view detection to trigger animation */
useInViewTrigger?: boolean;
/** In-view options */
inViewOptions?: UseInViewOptions;
/** Spring transition options */
transition?: SpringOptions;
}
function GithubButton({
initialStars = 0,
targetStars = 0,
starsClass = '',
fixedWidth = true,
animationDuration = 2,
animationDelay = 0,
autoAnimate = true,
className,
variant = 'default',
size = 'default',
showGithubIcon = true,
showStarIcon = true,
roundStars = false,
separator = false,
filled = false,
repoUrl,
onClick,
label = '',
useInViewTrigger = false,
inViewOptions = { once: true },
transition,
...props
}: GithubButtonProps) {
const [currentStars, setCurrentStars] = useState(initialStars);
const [isAnimating, setIsAnimating] = useState(false);
const [starProgress, setStarProgress] = useState(filled ? 100 : 0);
const [hasAnimated, setHasAnimated] = useState(false);
// Format number with units
const formatNumber = (num: number) => {
const units = ['k', 'M', 'B', 'T'];
if (roundStars && num >= 1000) {
let unitIndex = -1;
let value = num;
while (value >= 1000 && unitIndex < units.length - 1) {
value /= 1000;
unitIndex++;
}
// Format to 1 decimal place if needed, otherwise show whole number
const formatted = value % 1 === 0 ? value.toString() : value.toFixed(1);
return `${formatted}${units[unitIndex]}`;
}
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
};
// Start animation
const startAnimation = useCallback(() => {
if (isAnimating || hasAnimated) return;
setIsAnimating(true);
const startTime = Date.now();
const startValue = 0; // Always start from 0 for number animation
const endValue = targetStars;
const duration = animationDuration * 1000;
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
// Easing function for smooth animation
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
// Update star count from 0 to target with more frequent updates
const newStars = Math.round(startValue + (endValue - startValue) * easeOutQuart);
setCurrentStars(newStars);
// Update star fill progress (0 to 100)
setStarProgress(progress * 100);
if (progress < 1) {
requestAnimationFrame(animate);
} else {
setCurrentStars(endValue);
setStarProgress(100);
setIsAnimating(false);
setHasAnimated(true);
}
};
setTimeout(() => {
requestAnimationFrame(animate);
}, animationDelay * 1000);
}, [isAnimating, hasAnimated, targetStars, animationDuration, animationDelay]);
// Use in-view detection if enabled
const ref = React.useRef(null);
const isInView = useInView(ref, inViewOptions);
// Reset animation state when targetStars changes
useEffect(() => {
setHasAnimated(false);
setCurrentStars(initialStars);
}, [targetStars, initialStars]);
// Auto-start animation or use in-view trigger
useEffect(() => {
if (useInViewTrigger) {
if (isInView && !hasAnimated) {
startAnimation();
}
} else if (autoAnimate && !hasAnimated) {
startAnimation();
}
}, [autoAnimate, useInViewTrigger, isInView, hasAnimated, startAnimation]);
const navigateToRepo = () => {
if (!repoUrl) {
return;
}
// Next.js compatible navigation approach
try {
// Create a temporary anchor element for reliable navigation
const link = document.createElement('a');
link.href = repoUrl;
link.target = '_blank';
link.rel = 'noopener noreferrer';
// Temporarily add to DOM and click
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch {
// Fallback to window.open
try {
window.open(repoUrl, '_blank', 'noopener,noreferrer');
} catch {
// Final fallback
window.location.href = repoUrl;
}
}
};
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
if (onClick) {
onClick(event);
return;
}
if (repoUrl) {
navigateToRepo();
} else if (!hasAnimated) {
startAnimation();
}
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
// Handle Enter and Space key presses for accessibility
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
if (repoUrl) {
navigateToRepo();
} else if (!hasAnimated) {
startAnimation();
}
}
};
return (
<button
ref={ref}
className={cn(githubButtonVariants({ variant, size, className }), separator && 'ps-0')}
onClick={handleClick}
onKeyDown={handleKeyDown}
role="button"
tabIndex={0}
aria-label={repoUrl ? `Star ${label} on GitHub` : label}
{...props}
>
{showGithubIcon && (
<div
className={cn(
'h-full relative flex items-center justify-center',
separator && 'w-9 bg-muted/60 border-e border-input',
)}
>
<svg role="img" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
</div>
)}
{label && <span>{label}</span>}
{/* Animated Star Icon */}
{showStarIcon && (
<div className="relative inline-flex shrink-0">
<Star className="fill-muted-foreground text-muted-foreground" aria-hidden="true" />
<Star
className="absolute top-0 start-0 text-yellow-400 fill-yellow-400"
size={18}
aria-hidden="true"
style={{
clipPath: `inset(${100 - starProgress}% 0 0 0)`,
}}
/>
</div>
)}
{/* Animated Number Counter with Ticker Effect */}
<div className={cn('flex flex-col font-semibold relative overflow-hidden', starsClass)}>
<motion.div
animate={{ opacity: 1 }}
transition={{
type: 'spring',
stiffness: 300,
damping: 30,
...transition,
}}
className="tabular-nums"
>
<span>{currentStars > 0 && formatNumber(currentStars)}</span>
</motion.div>
{fixedWidth && <span className="opacity-0 h-0 overflow-hidden tabular-nums">{formatNumber(targetStars)}</span>}
</div>
</button>
);
}
export { GithubButton, githubButtonVariants };
export type { GithubButtonProps };
@@ -0,0 +1,178 @@
"use client";
import {Ref, useEffect, useState} from "react";
import {Check, X} from "lucide-react";
import {motion, AnimatePresence} from "framer-motion";
import {
FormControl,
FormDescription,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {PasswordInput} from "@/components/ui/password-input";
interface PasswordStrengthInputProps {
field: {
name: string;
value: string;
onChange: (value: string) => void;
onBlur: () => void;
ref: Ref<HTMLInputElement>;
};
label?: string;
description?: string;
disabled?: boolean;
onValidChange?: (isValid: boolean) => void;
}
const passwordTextsFields = {
label: "Password",
placeholder: "Enter password",
strength: {
enter: "Enter a password",
weak: "Weak password",
medium: "Medium password",
strong: "Strong password"
},
requirementsIntro: "Must contain:",
requirements: {
minLength: "At least 8 characters",
number: "At least 1 number",
lowercase: "At least 1 lowercase letter",
uppercase: "At least 1 uppercase letter",
specialChar: "At least 1 special character"
}
}
export function PasswordStrengthInput({
field,
label,
description,
disabled, onValidChange
}: PasswordStrengthInputProps) {
const text = passwordTextsFields
const [isVisible, setIsVisible] = useState(false);
const password = field.value ?? "";
const requirements = [
{regex: /.{8,}/, text: text.requirements.minLength},
{regex: /[0-9]/, text: text.requirements.number},
{regex: /[a-z]/, text: text.requirements.lowercase},
{regex: /[A-Z]/, text: text.requirements.uppercase},
{regex: /[^a-zA-Z0-9]/, text: text.requirements.specialChar},
];
const strength = requirements.map((req) => ({
met: req.regex.test(password),
text: req.text,
}));
const strengthScore = strength.filter((req) => req.met).length;
const getStrengthColor = (score: number) => {
if (score === 0) return "bg-border";
if (score <= 2) return "bg-red-500";
if (score === 3) return "bg-orange-500";
if (score === 4) return "bg-amber-500";
return "bg-emerald-500";
};
const getStrengthText = (score: number) => {
if (score === 0) return text.strength.enter;
if (score <= 2) return text.strength.weak;
if (score === 3 || score === 4) return text.strength.medium;
return text.strength.strong;
};
useEffect(() => {
if (strengthScore === requirements.length) {
onValidChange?.(true);
} else {
onValidChange?.(false);
}
}, [strengthScore, onValidChange]);
return (
<FormItem>
<FormLabel>{label ?? text.label}</FormLabel>
<div className="relative">
<FormControl>
<PasswordInput
placeholder={text.placeholder}
value={field.value ?? ""}
onChange={(e) => field.onChange(e.target.value)}
onFocus={() => setIsVisible(true)}
onBlur={(e) => {
field.onBlur();
setIsVisible(false);
}}
ref={field.ref}
name={field.name}
disabled={disabled}
/>
</FormControl>
</div>
<AnimatePresence>
{isVisible && (
<motion.div
initial={{opacity: 0, height: 0, y: -5}}
animate={{opacity: 1, height: "auto", y: 0}}
exit={{opacity: 0, height: 0, y: -5}}
transition={{duration: 0.35, ease: "easeInOut"}}
className="overflow-hidden"
>
<FormDescription>
{description ??
`${getStrengthText(strengthScore)}. ${text.requirementsIntro}`}
</FormDescription>
<FormMessage/>
<div
className="mb-2 h-1 w-full overflow-hidden rounded-full bg-border mt-2"
role="progressbar"
aria-valuenow={strengthScore}
aria-valuemin={0}
aria-valuemax={requirements.length}
>
<motion.div
key={strengthScore}
className={`h-full ${getStrengthColor(
strengthScore
)}`}
initial={{width: 0}}
animate={{
width: `${(strengthScore / requirements.length) * 100}%`,
}}
transition={{duration: 0.5}}
/>
</div>
<ul className="space-y-1.5" aria-label="Password requirements">
{strength.map((req, i) => (
<li key={i} className="flex items-center gap-2">
{req.met ? (
<Check className="h-4 w-4 text-emerald-500"/>
) : (
<X className="h-4 w-4 text-muted-foreground"/>
)}
<span
className={`text-xs ${
req.met ? "text-emerald-600" : "text-muted-foreground"
}`}
>
{req.text}
</span>
</li>
))}
</ul>
</motion.div>
)}
</AnimatePresence>
</FormItem>
);
}
+235
View File
@@ -0,0 +1,235 @@
'use client';
import * as React from 'react';
import {
useSpring,
useTransform,
motion,
useInView,
type MotionValue,
type SpringOptions,
type UseInViewOptions,
} from 'motion/react';
import useMeasure from 'react-use-measure';
import {cn} from "@/lib/utils";
type SlidingNumberRollerProps = {
prevValue: number;
value: number;
place: number;
transition: SpringOptions;
};
function SlidingNumberRoller({
prevValue,
value,
place,
transition,
}: SlidingNumberRollerProps) {
const startNumber = Math.floor(prevValue / place) % 10;
const targetNumber = Math.floor(value / place) % 10;
const animatedValue = useSpring(startNumber, transition);
React.useEffect(() => {
animatedValue.set(targetNumber);
}, [targetNumber, animatedValue]);
const [measureRef, { height }] = useMeasure();
return (
<span
ref={measureRef}
data-slot="sliding-number-roller"
className="relative inline-block w-[1ch] overflow-x-visible overflow-y-clip leading-none tabular-nums"
>
<span className="invisible">0</span>
{Array.from({ length: 10 }, (_, i) => (
<SlidingNumberDisplay
key={i}
motionValue={animatedValue}
number={i}
height={height}
transition={transition}
/>
))}
</span>
);
}
type SlidingNumberDisplayProps = {
motionValue: MotionValue<number>;
number: number;
height: number;
transition: SpringOptions;
};
function SlidingNumberDisplay({
motionValue,
number,
height,
transition,
}: SlidingNumberDisplayProps) {
const y = useTransform(motionValue, (latest) => {
if (!height) return 0;
const currentNumber = latest % 10;
const offset = (10 + number - currentNumber) % 10;
let translateY = offset * height;
if (offset > 5) translateY -= 10 * height;
return translateY;
});
if (!height) {
return <span className="invisible absolute">{number}</span>;
}
return (
<motion.span
data-slot="sliding-number-display"
style={{ y }}
className="absolute inset-0 flex items-center justify-center"
transition={{ ...transition, type: 'spring' }}
>
{number}
</motion.span>
);
}
type SlidingNumberProps = React.ComponentProps<'span'> & {
number: number | string;
inView?: boolean;
inViewMargin?: UseInViewOptions['margin'];
inViewOnce?: boolean;
padStart?: boolean;
decimalSeparator?: string;
decimalPlaces?: number;
transition?: SpringOptions;
};
function SlidingNumber({
ref,
number,
className,
inView = false,
inViewMargin = '0px',
inViewOnce = true,
padStart = false,
decimalSeparator = '.',
decimalPlaces = 0,
transition = {
stiffness: 200,
damping: 20,
mass: 0.4,
},
...props
}: SlidingNumberProps) {
const localRef = React.useRef<HTMLSpanElement>(null);
React.useImperativeHandle(ref, () => localRef.current!);
const inViewResult = useInView(localRef, {
once: inViewOnce,
margin: inViewMargin,
});
const isInView = !inView || inViewResult;
const prevNumberRef = React.useRef<number>(0);
const effectiveNumber = React.useMemo(
() => (!isInView ? 0 : Math.abs(Number(number))),
[number, isInView],
);
const formatNumber = React.useCallback(
(num: number) =>
decimalPlaces != null ? num.toFixed(decimalPlaces) : num.toString(),
[decimalPlaces],
);
const numberStr = formatNumber(effectiveNumber);
const [newIntStrRaw, newDecStrRaw = ''] = numberStr.split('.');
const newIntStr =
padStart && newIntStrRaw?.length === 1 ? '0' + newIntStrRaw : newIntStrRaw;
const prevFormatted = formatNumber(prevNumberRef.current);
const [prevIntStrRaw = '', prevDecStrRaw = ''] = prevFormatted.split('.');
const prevIntStr =
padStart && prevIntStrRaw.length === 1
? '0' + prevIntStrRaw
: prevIntStrRaw;
const adjustedPrevInt = React.useMemo(() => {
return prevIntStr.length > (newIntStr?.length ?? 0)
? prevIntStr.slice(-(newIntStr?.length ?? 0))
: prevIntStr.padStart(newIntStr?.length ?? 0, '0');
}, [prevIntStr, newIntStr]);
const adjustedPrevDec = React.useMemo(() => {
if (!newDecStrRaw) return '';
return prevDecStrRaw.length > newDecStrRaw.length
? prevDecStrRaw.slice(0, newDecStrRaw.length)
: prevDecStrRaw.padEnd(newDecStrRaw.length, '0');
}, [prevDecStrRaw, newDecStrRaw]);
React.useEffect(() => {
if (isInView) prevNumberRef.current = effectiveNumber;
}, [effectiveNumber, isInView]);
const intDigitCount = newIntStr?.length ?? 0;
const intPlaces = React.useMemo(
() =>
Array.from({ length: intDigitCount }, (_, i) =>
Math.pow(10, intDigitCount - i - 1),
),
[intDigitCount],
);
const decPlaces = React.useMemo(
() =>
newDecStrRaw
? Array.from({ length: newDecStrRaw.length }, (_, i) =>
Math.pow(10, newDecStrRaw.length - i - 1),
)
: [],
[newDecStrRaw],
);
const newDecValue = newDecStrRaw ? parseInt(newDecStrRaw, 10) : 0;
const prevDecValue = adjustedPrevDec ? parseInt(adjustedPrevDec, 10) : 0;
return (
<span
ref={localRef}
data-slot="sliding-number"
className={cn('flex items-center', className)}
{...props}
>
{isInView && Number(number) < 0 && <span className="mr-1">-</span>}
{intPlaces.map((place) => (
<SlidingNumberRoller
key={`int-${place}`}
prevValue={parseInt(adjustedPrevInt, 10)}
value={parseInt(newIntStr ?? '0', 10)}
place={place}
transition={transition}
/>
))}
{newDecStrRaw && (
<>
<span>{decimalSeparator}</span>
{decPlaces.map((place) => (
<SlidingNumberRoller
key={`dec-${place}`}
prevValue={prevDecValue}
value={newDecValue}
place={place}
transition={transition}
/>
))}
</>
)}
</span>
);
}
export { SlidingNumber, type SlidingNumberProps };
@@ -0,0 +1,31 @@
"use client"
import {env} from "@/env.mjs";
import React, {useEffect, useState} from "react";
import {useTheme} from "next-themes";
export const AuthLogoSection = () => {
const {resolvedTheme} = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
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>
)
}
@@ -0,0 +1,24 @@
"use client";
import { toast } from "sonner";
import { useRouter, useSearchParams } from "next/navigation";
import TwoFactorForm from "@/components/wrappers/dashboard/profile/form/2fa-form";
export const GuardForm = () => {
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("redirect") || "/dashboard";
return (
<TwoFactorForm
onSuccess={(success) => {
if (success) {
toast.success("Successfully logged in!");
//@ts-ignore
router.push(callbackUrl);
router.refresh();
}
}}
/>
);
};
@@ -1,54 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import { signIn } from "@/lib/auth/auth-client";
import { JSX } from "react";
export type AuthButtonProps = {
providers: SocialProviderType[];
};
export type SocialProviderType = {
id:
| "github"
| "apple"
| "discord"
| "facebook"
| "microsoft"
| "google"
| "spotify"
| "twitch"
| "twitter"
| "dropbox"
| "kick"
| "linkedin"
| "gitlab"
| "tiktok"
| "reddit"
| "roblox"
| "vk";
name: string;
icon: JSX.Element;
};
export const SocialAuthButton = (props: AuthButtonProps): JSX.Element => {
return (
<div className="flex flex-col gap-4 mt-5">
{props.providers.map((provider) => (
<Button
key={provider.id}
aria-label={`Sign in with ${provider.name}`}
onClick={(e) => {
e.preventDefault();
void signIn.social({
provider: provider.id,
callbackURL: "/dashboard/profile",
});
}}
>
{provider.icon}
Sign in with {provider.name}
</Button>
))}
</div>
);
};
@@ -0,0 +1,10 @@
"use client";
import { z } from "zod";
import { zEmail } from "@/lib/zod";
export const ForgotPasswordSchema = z.object({
email: zEmail(),
});
export type ForgotPasswordType = z.infer<typeof ForgotPasswordSchema>;
@@ -0,0 +1,81 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Form } from "@/components/ui/form";
import { requestPasswordReset } from "@/lib/auth/auth-client";
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
import Link from "next/link";
import { ForgotPasswordSchema, ForgotPasswordType } from "./forgot-password-form.schema";
import { ArrowLeft } from "lucide-react";
export type ForgotPasswordFormProps = {
defaultValues?: ForgotPasswordType;
};
export const ForgotPasswordForm = (props: ForgotPasswordFormProps) => {
const form = useZodForm({
schema: ForgotPasswordSchema,
});
const mutation = useMutation({
mutationFn: async (values: ForgotPasswordType) => {
await requestPasswordReset(
{
email: values.email,
},
{
onSuccess: () => {
toast.success("Password reset successfully.");
},
onError: (error) => {
toast.error(error.error.message);
},
}
);
},
});
return (
<Form
form={form}
className="flex flex-col gap-4 mb-1"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="email"
defaultValue=""
render={({ field }) => (
<FormItem>
<FormLabel>Email Address</FormLabel>
<FormControl>
<Input autoComplete="email" autoFocus
placeholder="example@portabase.io"
{...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-col items-center gap-y-6 w-full">
<ButtonWithLoading className="mt-2 w-full" isPending={mutation.isPending}>
Send reset link
</ButtonWithLoading>
<Link href="/login" className="group flex items-center text-sm hover:underline">
<ArrowLeft className="mr-1 size-4 text-muted-foreground transition-transform group-hover:-translate-x-1" />
Back to login
</Link>
</div>
</Form>
);
};
@@ -0,0 +1,75 @@
"use server";
import { ServerActionResult } from "@/types/action-type";
import { auth } from "@/lib/auth/auth";
import { zString } from "@/lib/zod";
import z from "zod";
import { db } from "@/db";
import {action} from "@/lib/safe-actions/actions";
//todo: to be continued...
export const forgotPasswordAction = action
.schema(
z.object({
schema: z.object({
email: zString(),
}),
redirectTo: zString().optional(),
})
)
.action(async ({ parsedInput }): Promise<ServerActionResult<null>> => {
try {
const user = await (await auth.$context).internalAdapter.findUserByEmail(parsedInput.schema.email);
if (!user) {
return {
success: false,
actionError: {
message: "password_reset",
cause: "user_not_found",
},
};
}
const existingToken = await db.query.verification.findFirst({
where: (verifications, { eq, and, gte }) =>
and(eq(verifications.value, user.user.id), gte(verifications.expiresAt, new Date(Date.now() + 15 * 60 * 1000))),
});
if (existingToken) {
return {
success: false,
actionError: {
message: "password_reset",
cause: "reset_already_requested",
},
};
}
// await (
// await auth.$context
// ).options.emailAndPassword
// .sendResetPassword(
// {
// user: user.user,
// url,
// token: verificationToken,
// },
// ctx.request
// )
// .catch((e) => {
// ctx.context.logger.error("Failed to send reset password email", e);
// });
return {
success: true,
};
} catch (error) {
return {
success: false,
actionError: {
message: "password_reset",
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
@@ -1,8 +1,11 @@
"use client";
import { z } from "zod";
import { zEmail, zString } from "@/lib/zod";
export const LoginSchema = z.object({
email: z.string(),
password: z.string(),
email: zEmail(),
password: zString().nonempty(),
});
export type LoginType = z.infer<typeof LoginSchema>;
@@ -1,123 +1,122 @@
"use client";
import {Card, CardContent, CardHeader} from "@/components/ui/card";
import {useEffect, useState} from "react";
import {useMutation} from "@tanstack/react-query";
import {toast} from "sonner";
import {FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Form} from "@/components/ui/form";
import {Button} from "@/components/ui/button";
import {toast} from "sonner";
import {useMutation} from "@tanstack/react-query";
import {TooltipProvider} from "@/components/ui/tooltip";
import Link from "next/link";
import {PasswordInput} from "@/components/wrappers/auth/password-input/password-input";
import {LoginSchema, LoginType} from "@/components/wrappers/auth/login/login-form/login-form.schema";
import {SocialAuthButton, SocialProviderType} from "@/components/wrappers/auth/login/button-auth/social-auth-button";
import {signIn} from "@/lib/auth/auth-client";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import Link from "next/link";
import {useRouter} from "next/navigation";
import {Icon} from "@iconify/react";
import {env} from "@/env.mjs";
import {PasswordInput} from "@/components/ui/password-input";
export type loginFormProps = {
defaultValues?: LoginType;
};
export const LoginForm = (props: loginFormProps) => {
const router = useRouter();
const [urlParams, setUrlParams] = useState<URLSearchParams>();
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
setUrlParams(urlParams);
const error = urlParams.get("error");
if (error?.includes("pending")) {
toast.error("Your account is not active.");
urlParams.delete("error");
window.history.replaceState({}, document.title, window.location.pathname + "?" + urlParams.toString());
}
if (error?.includes("invalid_or_expired_token")) {
toast.error("Password reset invalid token.");
urlParams.delete("error");
window.history.replaceState({}, document.title, window.location.pathname + "?" + urlParams.toString());
}
}, []);
const form = useZodForm({
schema: LoginSchema,
});
const router = useRouter();
const mutation = useMutation({
mutationFn: async (values: LoginType) => {
const {error} = await signIn.email(values, {
onSuccess: () => {
toast.success("Login success");
router.push("/dashboard/profile");
await signIn.email(
{
password: values.password,
email: values.email,
callbackURL: urlParams?.get("redirect") ?? "/dashboard",
},
});
if (error) {
toast.error(error.message);
}
{
onSuccess: (context) => {
if (context.data.twoFactorRedirect) {
//@ts-ignore
router.push("/guard?redirect=" + encodeURIComponent(context.data.callbackURL || "/dashboard"));
}
toast.success("Login success");
},
onError: (error) => {
toast.error(error.error.message);
},
}
);
},
});
const availableProviders: SocialProviderType[] = [];
if (env.NEXT_PUBLIC_GOOGLE_AUTH) {
availableProviders.push(
{
id: "google",
name: "Google",
icon: <Icon icon={"logos:google-icon"} width="25" height="25"/>,
},
)
}
return (
<TooltipProvider>
<Card>
<CardHeader>
<div className="grid gap-2 text-center mb-2">
<h1 className="text-3xl font-bold">Login</h1>
<p className="text-balance text-muted-foreground">Enter your informations below to login</p>
</div>
</CardHeader>
<CardContent>
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="email"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input autoComplete="email webauthn"
placeholder="exemple@portabase.io" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
defaultValue=""
render={({field}) => (
<FormItem>
<div className="flex items-center">
<FormLabel>Password</FormLabel>
{/* <Link href={"/forgot-password"} className="ml-auto inline-block text-sm underline">
Forgot your password?
</Link>*/}
</div>
<FormControl>
<PasswordInput autoComplete="current-password webauthn"
placeholder="Your password" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<Button>Sign in</Button>
<div className="mt-4 text-center text-sm">
Don&apos;t have an account?{" "}
<Link href={"/register"} className="underline">
Sign up
</Link>
<Form
form={form}
className="flex flex-col gap-4 mb-1"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="email"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Email Address</FormLabel>
<FormControl>
<Input autoComplete="email" autoFocus placeholder="exemple@portabase.io" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
defaultValue=""
render={({field}) => (
<FormItem>
<div className="flex items-center justify-between">
<FormLabel>Password</FormLabel>
<div className="text-center text-sm">
<Link href={"/forgot-password"} className="hover:underline ml-1">
Forgot your password ?
</Link>
</div>
</div>
</Form>
<SocialAuthButton providers={availableProviders}/>
</CardContent>
</Card>
</TooltipProvider>
<FormControl>
<PasswordInput autoComplete="current-password webauthn"
placeholder={"Enter your password"} {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<ButtonWithLoading className="mt-2" isPending={mutation.isPending}>
Login
</ButtonWithLoading>
</Form>
);
};
@@ -0,0 +1,85 @@
"use server";
import { ServerActionResult } from "@/types/action-type";
import { auth } from "@/lib/auth/auth";
import { zPassword, zString } from "@/lib/zod";
import z from "zod";
import {action} from "@/lib/safe-actions/actions";
export const resetPasswordAction = action
.schema(
z.object({
schema: z.object({
password: zPassword(),
}),
token: zString(),
})
)
.action(async ({ parsedInput }): Promise<ServerActionResult<null>> => {
try {
const verification = await (await auth.$context).internalAdapter.findVerificationValue(`reset-password:${parsedInput.token}`);
if (!verification || verification.expiresAt < new Date()) {
return {
success: false,
actionError: {
message: "password_reset",
cause: "invalid_or_expired_token",
},
};
}
const user = await (await auth.$context).internalAdapter.findUserById(verification.value);
console.log(user)
if (!user) {
return {
success: false,
actionError: {
message: "password_reset",
cause: "user_not_found",
},
};
}
const hashedPassword = await (await auth.$context).password.hash(parsedInput.schema.password);
console.log(hashedPassword)
console.log("ok")
// await (await auth.$context).internalAdapter.updatePassword(user.id, hashedPassword);
console.log("ici")
// await (await auth.$context).internalAdapter.deleteSessions(user.id);
// console.log("ici2")
// await (await auth.$context).internalAdapter.deleteVerificationValue(verification.id);
// console.log("ici3");
//
// (await auth.$context).internalAdapter.updateUser(user.id, {
// lastChangedPasswordAt: new Date(),
// });
// await auth.api.resetPassword({
// headers: await headers(),
// body: {
// newPassword: parsedInput.schema.password,
// token: parsedInput.token,
// },
// });
await (
await auth.$context
).internalAdapter.updateUser(user.id, {
isDefaultPassword: false,
});
return {
success: true,
actionSuccess: {
message: "password_reset",
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "password_reset",
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
@@ -0,0 +1,21 @@
"use client";
import {z} from "zod";
import {zPassword} from "@/lib/zod";
export const ResetPasswordSchema = z
.object({
password: zPassword(),
confirmPassword: zPassword(),
})
.superRefine(({confirmPassword, password}, ctx) => {
if (confirmPassword !== password) {
ctx.addIssue({
code: "custom",
message: "Confirmation password does not match",
path: ["confirmPassword"],
});
}
});
export type ResetPasswordType = z.infer<typeof ResetPasswordSchema>;
@@ -0,0 +1,97 @@
"use client";
import {useMutation} from "@tanstack/react-query";
import {toast} from "sonner";
import {FormControl, FormField, FormItem, FormLabel, useZodForm} from "@/components/ui/form";
import {Form} from "@/components/ui/form";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import Link from "next/link";
import {ResetPasswordSchema, ResetPasswordType} from "./reset-password-form.schema";
import {PasswordStrengthInput} from "@/components/ui/password-input-indicator";
import {useRouter, useSearchParams} from "next/navigation";
import {ArrowLeft} from "lucide-react";
import {authClient} from "@/lib/auth/auth-client";
import {BetterAuthError} from "@/types/auth";
import {PasswordInput} from "@/components/ui/password-input";
export type ResetPasswordFormProps = {
defaultValues?: ResetPasswordType;
};
export const ResetPasswordForm = (props: ResetPasswordFormProps) => {
const searchParams = useSearchParams();
const form = useZodForm({
schema: ResetPasswordSchema,
});
const router = useRouter();
const mutation = useMutation({
mutationFn: async (values: ResetPasswordType) => {
const {data, error} = await authClient.resetPassword({
newPassword: values.password,
token: searchParams.get("token") || "",
});
if (error) throw error;
},
onSuccess: () => {
toast.success("Password successfully reset!");
setTimeout(() => router.push("/"), 1400);
},
onError: (error: BetterAuthError) => {
console.log(error)
toast.error("An error occurred while resetting password");
},
});
return (
<Form
form={form}
className="flex flex-col gap-4 mb-1"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="password"
defaultValue=""
render={({field}) => (
<FormItem>
<PasswordStrengthInput label={"New password"} field={field}/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Confirmation password</FormLabel>
<FormControl>
<PasswordInput placeholder={"Enter your conformation password"} {...field} />
</FormControl>
</FormItem>
)}
/>
<div className="flex flex-col items-center gap-y-6 w-full">
<ButtonWithLoading className="mt-2 w-full" isPending={mutation.isPending}>
Reset
</ButtonWithLoading>
<Link href="/login" className="group flex items-center text-sm hover:underline">
<ArrowLeft
className="mr-1 size-4 text-muted-foreground transition-transform group-hover:-translate-x-1"/>
Back to login
</Link>
</div>
</Form>
);
};
@@ -1,19 +1,20 @@
"use client";
import { useRouter } from "next/navigation";
import { Info } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import {useRouter} from "next/navigation";
import {Info} from "lucide-react";
import {useMutation} from "@tanstack/react-query";
import {toast} from "sonner";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Form } from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import { TooltipProvider, TooltipTrigger, Tooltip, TooltipContent } from "@/components/ui/tooltip";
import { RegisterSchema, RegisterType } from "@/components/wrappers/auth/register/register-form/register-form.schema";
import { PasswordInput } from "@/components/wrappers/auth/password-input/password-input";
import {Card, CardContent, CardHeader} from "@/components/ui/card";
import {FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Form} from "@/components/ui/form";
import {Button} from "@/components/ui/button";
import {TooltipProvider, TooltipTrigger, Tooltip, TooltipContent} from "@/components/ui/tooltip";
import {RegisterSchema, RegisterType} from "@/components/wrappers/auth/register/register-form/register-form.schema";
import {PasswordInput} from "@/components/ui/password-input";
import {signUp} from "@/lib/auth/auth-client";
import Link from "next/link";
export type registerFormProps = {
defaultValues?: RegisterType;
@@ -29,7 +30,7 @@ export const RegisterForm = (props: registerFormProps) => {
mutationFn: async (values: RegisterType) => {
await signUp.email(values, {
onSuccess: () => {
toast.success(`Success`);
toast.success(`Account successfully created`);
router.refresh();
router.push(`/login`);
},
@@ -63,13 +64,13 @@ export const RegisterForm = (props: registerFormProps) => {
control={form.control}
name="name"
defaultValue=""
render={({ field }) => (
render={({field}) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Your name" {...field} />
</FormControl>
<FormMessage />
<FormMessage/>
</FormItem>
)}
/>
@@ -77,13 +78,13 @@ export const RegisterForm = (props: registerFormProps) => {
control={form.control}
name="email"
defaultValue=""
render={({ field }) => (
render={({field}) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="exemple@portabase.io" {...field} />
</FormControl>
<FormMessage />
<FormMessage/>
</FormItem>
)}
/>
@@ -91,18 +92,19 @@ export const RegisterForm = (props: registerFormProps) => {
control={form.control}
name="password"
defaultValue=""
render={({ field }) => (
render={({field}) => (
<FormItem>
<FormLabel className="flex">
Password
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="ml-3" size="15" />
<Info className="ml-3" size="15"/>
</TooltipTrigger>
<TooltipContent>
<p>
Min. 8 characters, 1 uppercase (A-Z), 1 lowercase (a-z), 1 number (0-9), 1 special character (!, @,
Min. 8 characters, 1 uppercase (A-Z), 1 lowercase (a-z), 1
number (0-9), 1 special character (!, @,
etc.)
</p>
</TooltipContent>
@@ -112,7 +114,7 @@ export const RegisterForm = (props: registerFormProps) => {
<FormControl>
<PasswordInput placeholder="Your password" {...field} />
</FormControl>
<FormMessage />
<FormMessage/>
</FormItem>
)}
/>
@@ -120,17 +122,26 @@ export const RegisterForm = (props: registerFormProps) => {
control={form.control}
name="confirmPassword"
defaultValue=""
render={({ field }) => (
render={({field}) => (
<FormItem>
<FormLabel>Password Confirmation</FormLabel>
<FormControl>
<PasswordInput placeholder="Conform your password" {...field} />
</FormControl>
<FormMessage />
<FormMessage/>
</FormItem>
)}
/>
<Button>Sign up</Button>
<Button type="submit" disabled={mutation.isPending}>
Sign up
</Button>
<div className="mt-4 text-center text-sm">
Already have an account ?{" "}
<Link href="/login" className="underline">
Sign in
</Link>
</div>
</Form>
</CardContent>
</Card>
@@ -0,0 +1,94 @@
"use client";
import {useRouter} from "next/navigation";
import {useMutation} from "@tanstack/react-query";
import {Form, FormControl, FormField, FormItem, FormLabel, useZodForm} from "@/components/ui/form";
import {PasswordStrengthInput} from "@/components/ui/password-input-indicator";
import {ResetPasswordSchema, ResetPasswordType} from "@/components/wrappers/auth/reset-password/reset-password-schema";
import {PasswordInput} from "@/components/ui/password-input";
import {Card, CardContent, CardHeader} from "@/components/ui/card";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {authClient} from "@/lib/auth/auth-client";
import {toast} from "sonner";
type ResetPasswordFormProps = {
token: string;
};
export const ResetPasswordForm = ({token}: ResetPasswordFormProps) => {
const router = useRouter();
const form = useZodForm({
schema: ResetPasswordSchema,
});
const mutationResetPassword = useMutation({
mutationFn: async (data: ResetPasswordType) => {
await authClient.resetPassword({
newPassword: data.password,
token,
}, {
onSuccess: (response) => {
console.log(response);
toast.success("Password changed successfully.");
setTimeout(() => router.push("/"), 1000);
},
onError: (error) => {
console.error(error);
toast.error(error.error.message);
},
});
},
});
return (
<Card>
<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">Fill information bellow to change your
password</p>
</div>
</CardHeader>
<CardContent>
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutationResetPassword.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="password"
defaultValue=""
render={({field}) => (
<FormItem>
<PasswordStrengthInput label={"Enter new password"} field={field}/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Confirmation Password</FormLabel>
<FormControl>
<PasswordInput placeholder={"Confirm password"} {...field}
value={field.value ?? ""}/>
</FormControl>
</FormItem>
)}
/>
<ButtonWithLoading type="submit" isPending={mutationResetPassword.isPending}>
Validate
</ButtonWithLoading>
</Form>
</CardContent>
</Card>
);
};
@@ -0,0 +1,22 @@
import {z} from "zod";
const passwordRegex = /^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-])/;
const zPassword = () => z.string().min(8, {message: "New Password too short"}).regex(passwordRegex, {message: "New Password too weak"});
export const ResetPasswordSchema = z
.object({
password: zPassword(),
confirmPassword: zPassword(),
})
.superRefine(({confirmPassword, password}, ctx) => {
if (confirmPassword !== password) {
ctx.addIssue({
code: "custom",
message: "Passwords don't match.",
path: ["confirmPassword"],
});
}
});
export type ResetPasswordType = z.infer<typeof ResetPasswordSchema>;
@@ -0,0 +1,37 @@
"use client"
import {useRouter, useSearchParams} from "next/navigation";
import {ResetPasswordForm} from "@/components/wrappers/auth/reset-password/reset-password-form";
import {toast} from "sonner";
import {useEffect} from "react";
import {LoadingSpinner} from "@/components/wrappers/common/loading/loading-spinner";
export const ResetPasswordSection = () => {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get("token");
const error = searchParams.get("error");
useEffect(() => {
if (error || !token) {
if (error == "INVALID_TOKEN") {
toast.error("Invalid reset token");
} else {
toast.error("An error occurred.");
}
setTimeout(() => router.push("/"), 1000);
}
}, [error, token, router]);
if (!token) return (
<div className="w-full h-full flex flex-col items-center justify-center">
<LoadingSpinner size={50}/>
</div>
);
return (
<ResetPasswordForm token={token}/>
)
}
@@ -0,0 +1,64 @@
"use client";
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth/auth-client";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import {SUPPORTED_PROVIDERS} from "../../../../portabase.config";
export function SocialAuthButtons() {
const [isLoading, setIsLoading] = useState<string | null>(null);
const handleSocialSignIn = async (providerId: string) => {
setIsLoading(providerId);
try {
const { error } = await authClient.signIn.social({
provider: providerId as "google" | "github",
callbackURL: "/dashboard",
});
if (error) {
toast.error("An error occurred while signing in with the provider. Please try again.");
} else {
toast.success("Redirecting to provider...");
}
} catch (err) {
toast.error("An error occurred while signing in with the provider. Please try again.");
} finally {
setIsLoading(null);
}
};
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" />}
<span>{PROVIDERS_TEXT[provider.id].title}</span>
</Button>
))}
</div>
);
}
const PROVIDERS_TEXT = {
credential: {
title: "Password",
description: "Use your email address and password to sign in."
},
google: {
title: "Google",
description: "Sign in with your Google account."
},
github: {
title: "GitHub",
description: "Sign in with your GitHub account."
}
}
@@ -44,7 +44,7 @@ export function BreadCrumbsWrapper() {
)
}
const FORBIDDEN_LINKS = ["organization", "dashboard", "database"];
const FORBIDDEN_LINKS = ["organization", "dashboard", "database", "admin", "settings", "notifications"];
export function BreadCrumbs({}: BreadCrumbsProps) {
@@ -0,0 +1,18 @@
"use client";
import React from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
type BackButtonProps = React.ComponentProps<typeof Button> & {
children: React.ReactNode;
};
export default function BackButton({ children, ...props }: BackButtonProps) {
const router = useRouter();
return (
<Button onClick={() => router.back()} aria-label={children?.toString()} {...props}>
{children}
</Button>
);
}
@@ -9,15 +9,15 @@ interface CardsWithPaginationProps<T> {
className?: string;
data: any[];
organizationSlug?: string;
cardItem: ComponentType<{ data: T; organizationSlug?: string; extendedProps?: any }>;
cardItem: ComponentType<{ data: T } & Record<string, any>>;
cardsPerPage?: number;
numberOfColumns?: number;
maxVisiblePages?: number;
extendedProps?: any;
[key: string]: any;
}
export function CardsWithPagination<T>(props: CardsWithPaginationProps<T>) {
const { className, organizationSlug, data, cardItem, cardsPerPage = 5, numberOfColumns = 1, maxVisiblePages = 3 } = props;
const { className, organizationSlug, data, cardItem, cardsPerPage = 5, numberOfColumns = 1, maxVisiblePages = 3, ...rest } = props;
const CardItem = cardItem;
@@ -44,7 +44,7 @@ export function CardsWithPagination<T>(props: CardsWithPaginationProps<T>) {
<div className={cn("flex flex-col h-full justify-between", className)}>
<div className={cn(`grid h-max auto-rows-min gap-4 md:grid-cols-${numberOfColumns}`)}>
{currentCards.map((card, key) => (
<CardItem key={key} data={card} organizationSlug={organizationSlug} extendedProps={props.extendedProps} />
<CardItem key={key} data={card} organizationSlug={organizationSlug} {...rest} />
))}
</div>
<PaginationNavigation
+51 -92
View File
@@ -6,78 +6,77 @@ import {cn} from "@/lib/utils";
import {Button} from "@/components/ui/button";
import {Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList} from "@/components/ui/command";
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover";
import {FormControl} from "@/components/ui/form";
import {SidebarMenuButton} from "@/components/ui/sidebar";
import {Separator} from "@/components/ui/separator";
import {CreateOrganizationModal} from "@/components/wrappers/dashboard/organization/create-organisation-modal";
export type comboBoxProps = {
values: Array<{ value: string; label: string }>;
defaultValue?: string;
onValueChange?: any;
export type ComboBoxProps<T = string> = {
values: Array<{ value: T; label: string }>;
defaultValue?: T;
onValueChangeAction?: (value: T) => void;
searchField?: boolean;
sideBar?: boolean;
reload?: () => void;
onAddItemAction?: () => void;
addItemLabel?: string;
};
export function ComboBox(props: comboBoxProps) {
export function ComboBox<T = string>(props: ComboBoxProps<T>) {
const {
values: choices,
defaultValue: defaultChoice = "",
onValueChange,
defaultValue,
onValueChangeAction,
searchField = false,
sideBar = false
sideBar = false,
onAddItemAction,
addItemLabel = "Add item",
} = props;
const [value, setValue] = useState<string>();
const [value, setValue] = useState<T | undefined>(defaultValue);
const [open, setOpen] = useState(false);
useEffect(() => {
setValue(defaultChoice);
}, [defaultChoice]);
const [open, setOpen] = useState(false);
const [openModal, setOpenModal] = useState(false);
setValue(defaultValue);
}, [defaultValue]);
return (
<div>
<CreateOrganizationModal
open={openModal}
onSuccess={() => {
props.reload?.();
}}
onOpenChange={setOpenModal}
/>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
{sideBar ? (
<SidebarMenuButton>
{value ? choices.find((choice) => choice.value === value)?.label : "Select choice..."}
{value ? choices.find((c) => c.value === value)?.label : "Select choice..."}
<ChevronDown className="ml-auto"/>
</SidebarMenuButton>
) : (
<Button variant="outline" role="combobox" aria-expanded={open}
className="w-full justify-between">
{value ? choices.find((choice) => choice.value === value)?.label : "Select choice..."}
{value ? choices.find((c) => c.value === value)?.label : "Select choice..."}
<ChevronDown className="opacity-50"/>
</Button>
)}
</PopoverTrigger>
<PopoverContent className="p-0 popover-content-width-full">
<PopoverContent
className="p-0"
align="start"
sideOffset={4}
style={{ width: 'var(--radix-popover-trigger-width)' }}
>
<Command>
{searchField ? <CommandInput placeholder="Search choice..." className="h-9"/> : null}
{searchField && <CommandInput placeholder="Search choice..." className="h-9"/>}
<CommandList>
<CommandEmpty>No choice found.</CommandEmpty>
<CommandGroup>
{choices.map((choice) => (
<CommandItem
key={choice.value}
value={choice.value}
key={String(choice.value)}
value={String(choice.value)}
onSelect={(currentValue) => {
setValue(currentValue);
onValueChange(currentValue);
setOpen(false);
const v = choices.find(c => String(c.value) === currentValue)?.value;
if (v !== undefined) {
setValue(v);
onValueChangeAction?.(v);
setOpen(false);
}
}}
>
{choice.label}
@@ -88,66 +87,26 @@ export function ComboBox(props: comboBoxProps) {
</CommandGroup>
</CommandList>
</Command>
<Separator/>
{sideBar ? (
<SidebarMenuButton onClick={() => {
setOpen(false)
setOpenModal(true)
}
}>+ Create new organization</SidebarMenuButton>
) : null}
{onAddItemAction && (
<>
<Separator/>
{sideBar ? (
<SidebarMenuButton onClick={() => {
setOpen(false);
onAddItemAction();
}}>
+ {addItemLabel}
</SidebarMenuButton>
) : (
<Button variant="outline" className="w-full" onClick={onAddItemAction}>
+ {addItemLabel}
</Button>
)}
</>
)}
</PopoverContent>
</Popover>
</div>
);
}
export type comboBoxFormItemProps = comboBoxProps & {
value: any;
name: any;
onChange: any;
};
export function ComboBoxFormItem(props: comboBoxFormItemProps) {
const {values: choices, searchField = false, value, name, onChange} = props;
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<FormControl>
<Button variant="outline" role="combobox" aria-expanded={open}
className={cn("w-full justify-between", !value && "text-muted-foreground")}>
{value ? choices.find((choice) => choice.value === value)?.label : `Select ${name}`}
<ChevronDown className="opacity-50"/>
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0 popover-content-width-full">
<Command>
{searchField ? <CommandInput placeholder="Search choice..." className="h-9"/> : null}
<CommandList>
<CommandEmpty>No {name} found.</CommandEmpty>
<CommandGroup>
{choices.map((choice) => (
<CommandItem
value={choice.label}
key={choice.value}
onSelect={() => {
onChange(choice.value);
setOpen(false);
}}
>
{choice.label}
<Check
className={cn("ml-auto", choice.value === value ? "opacity-100" : "opacity-0")}/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
@@ -1,22 +1,22 @@
import { cn } from "@/lib/utils";
import {cn} from "@/lib/utils";
export type ConnectionCircleProps = {
date?: Date | null;
};
export const ConnectionCircle = ({ date }: ConnectionCircleProps) => {
export const ConnectionCircle = ({date}: ConnectionCircleProps) => {
let style = "bg-gray-300 border-gray-400";
if (date instanceof Date && !isNaN(date.getTime())) {
const now = Date.now();
const timestamp = date.getTime();
const interval = now - timestamp;
const interval_seconds = (now - timestamp) / 1000;
console.log({ now, timestamp, interval });
console.log({now, timestamp, interval_seconds});
if (interval < 10000) {
if (interval_seconds < 55) {
style = "bg-green-400 border-green-600";
} else if (interval <= 20000) {
} else if (interval_seconds <= 60) {
style = "bg-orange-400 border-orange-600";
} else {
style = "bg-red-400 border-red-600";
@@ -27,5 +27,5 @@ export const ConnectionCircle = ({ date }: ConnectionCircleProps) => {
console.log(style);
return <div className={cn("w-5 h-5 rounded-full border-4", style)} />;
return <div className={cn("w-5 h-5 rounded-full border-4", style)}/>;
};
@@ -4,28 +4,38 @@ import {Plus} from "lucide-react";
type EmptyStatePlaceholderProps = {
url?: string;
onClick?: () => void;
text: string;
}
className?: string;
};
export const EmptyStatePlaceholder = ({url, text}: EmptyStatePlaceholderProps) => {
return (
<>{url ?
<Link
href={url}
className={cn(
"flex flex-col items-center justify-center w-full rounded-2xl border border-dashed border-muted p-6 lg:p-10",
"hover:bg-muted/50 transition-colors text-muted-foreground hover:text-primary text-center space-y-2"
)}
>
<Plus className="w-5 h-5 lg:w-6 lg:h-6"/>
<span className="text-sm lg:text-base font-medium">{text}</span>
</Link>
:
<div className="flex flex-col items-center justify-center py-12 text-center">
<p className="text-lg text-muted-foreground">{text}</p>
export const EmptyStatePlaceholder = ({
url,
onClick,
text,
className,
}: EmptyStatePlaceholderProps) => {
const Container = (
<div
className={cn(
"flex h-full flex-col items-center justify-center w-full rounded-2xl border border-dashed border-muted p-6 lg:p-10",
"hover:bg-muted/50 transition-colors text-muted-foreground hover:text-primary text-center space-y-4",
(onClick || url) && "cursor-pointer"
)}
onClick={onClick}
>
<Plus className="w-5 h-5 lg:w-6 lg:h-6" />
<p className="text-sm">{text}</p>
</div>
);
if (url) {
return (
<div className={cn(className)}>
<Link href={url}>{Container}</Link>
</div>
}
</>
);
}
)
}
return <div className={cn(className)}>{Container}</div>;
};
@@ -0,0 +1,47 @@
'use client';
import {GithubButton} from "@/components/ui/github-button";
import * as React from "react";
import {useEffect} from "react";
export const GitHubStarsButtonCustom = () => {
const [stars, setStars] = React.useState(0);
const [isLoading, setIsLoading] = React.useState(true);
const username = "Soluce-Technologies"
const repo = "portabase"
useEffect(() => {
fetch(`https://api.github.com/repos/${username}/${repo}`)
.then((response) => response.json())
.then((data) => {
if (data && typeof data.stargazers_count === 'number') {
setStars(data.stargazers_count);
}
})
.catch(console.error)
.finally(() => setIsLoading(false));
}, [username, repo]);
if (isLoading) return null;
return (
<div className=" gap-4 flex-wrap hidden md:block">
<GithubButton
initialStars={0}
targetStars={stars}
label="Github Stars"
size="sm"
separator={true}
roundStars={true}
repoUrl={`https://github.com/${username}/${repo}`}
variant="outline"
/>
</div>
);
};
export default GitHubStarsButtonCustom;
@@ -1,28 +1,36 @@
import {Icon} from "@iconify/react";
import {CircleHelp, KeyRound} from "lucide-react";
import {cn} from "@/lib/utils";
export const providerSwitch = (provider: string) => {
export const providerSwitch = (provider: string, small?: boolean) => {
switch (provider) {
case "google":
return (
<div className="p-4">
<Icon icon={"logos:google"} height="24" />
<div className={cn(small ? "p-0" : "p-4")}>
{small ?
<Icon icon={"flat-color-icons:google"} height="24"/>
:
<Icon icon={"logos:google"} height="24"/>
}
</div>
);
case "credential":
return (
<div className="flex flex-row gap-x-2 items-center p-4">
<KeyRound height="24" />
<span>Email and Password</span>
<div className={cn("flex flex-row gap-x-2 items-center", small ? "p-0" : "p-4")}>
<KeyRound height="24"/>
{!small && (<span>Email and Password</span>)}
</div>
);
default:
return (
<div className="flex flex-row gap-x-2 items-center p-4">
<CircleHelp height="24" />
<span>No credentials</span>
<div className={cn("flex flex-row gap-x-2 items-center", small ? "p-0" : "p-4")}>
<CircleHelp height="24"/>
{!small && (<span>No credentials</span>)}
</div>
);
}
}
}
@@ -17,7 +17,7 @@ export const TablePaginationSize = (props: tablePaginationSizeProps) => {
return (
<div className={cn("flex items-center justify-end sm:justify-center space-x-2", className)}>
<p className="whitespace-nowrap text-sm font-medium">Rows per page</p>
<p className="whitespace-nowrap text-sm font-medium hidden md:block">Rows per page</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
@@ -1,8 +1,8 @@
"use client";
import { TablePaginationNavigation } from "@/components/wrappers/common/table/table-pagination-navigation";
import { TablePaginationSize } from "@/components/wrappers/common/table/table-pagination-size";
import { cn } from "@/lib/utils";
import {TablePaginationNavigation} from "@/components/wrappers/common/table/table-pagination-navigation";
import {TablePaginationSize} from "@/components/wrappers/common/table/table-pagination-size";
import {cn} from "@/lib/utils";
interface tablePaginationProps {
className?: string;
@@ -12,12 +12,14 @@ interface tablePaginationProps {
}
export function TablePagination(props: tablePaginationProps) {
const { className, table, maxVisiblePages = 3, pageSizeOptions = [10, 20, 30, 40, 50] } = props;
const {className, table, maxVisiblePages = 3, pageSizeOptions = [10, 20, 30, 40, 50]} = props;
return (
<div className={cn("flex flex-col gap-x-4 sm:flex-row", className)}>
<TablePaginationSize table={table} pageSizeOptions={pageSizeOptions} />
<TablePaginationNavigation table={table} maxVisiblePages={maxVisiblePages} className="justify-end mt-5 sm:mt-0" />
<div
className={cn("flex gap-x-4", className)}
>
<TablePaginationSize table={table} pageSizeOptions={pageSizeOptions}/>
<TablePaginationNavigation table={table} maxVisiblePages={maxVisiblePages} className="justify-end mt-0"/>
</div>
);
}
@@ -1,67 +0,0 @@
"use client";
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
import {SettingsEmailTab} from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/settings-email-tab";
import {SettingsStorageTab} from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/settings-storage-tab";
import {User, UserWithAccounts} from "@/db/schema/02_user";
import {Setting} from "@/db/schema/01_setting";
import {useEffect, useState} from "react";
import {useRouter, useSearchParams} from "next/navigation";
import {AdminUsersTable} from "@/components/wrappers/dashboard/admin/tabs/admin-user-tab/admin-user-table";
import {
AdminOrganizationsTable
} from "@/components/wrappers/dashboard/admin/tabs/admin-organizations-tab/admin-organizations-table";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
export type AdminTabsProps = {
users: UserWithAccounts[];
settings: Setting;
organizations: OrganizationWithMembers[];
};
export const AdminTabs = ({users, settings, organizations}: AdminTabsProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const [tab, setTab] = useState<string>(() => searchParams.get("tab") ?? "users");
useEffect(() => {
const newTab = searchParams.get("tab") ?? "users";
setTab(newTab);
}, [searchParams]);
const handleChangeTab = (value: string) => {
router.push(`?tab=${value}`);
};
return (
<Tabs className="h-full" value={tab} onValueChange={handleChangeTab}>
<TabsList className="w-full">
<TabsTrigger className="w-full" value="users">
Users
</TabsTrigger>
<TabsTrigger className="w-full" value="organizations">
Organizations
</TabsTrigger>
<TabsTrigger className="w-full" value="email">
Email
</TabsTrigger>
<TabsTrigger className="w-full" value="storage">
Storage
</TabsTrigger>
</TabsList>
<TabsContent value="users">
<AdminUsersTable users={users}/>
</TabsContent>
<TabsContent value="organizations">
<AdminOrganizationsTable organizations={organizations}/>
</TabsContent>
<TabsContent value="email">
<SettingsEmailTab settings={settings}/>
</TabsContent>
<TabsContent value="storage">
<SettingsStorageTab settings={settings}/>
</TabsContent>
</Tabs>
);
};
@@ -0,0 +1,50 @@
"use client"
import {NotificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
import {NotifierCard} from "@/components/wrappers/dashboard/common/notifier/notifier-card/notifier-card";
import {useState} from "react";
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {NotifierAddEditModal} from "@/components/wrappers/dashboard/common/notifier/notifier-add-edit-modal";
type NotificationChannelsSectionProps = {
notificationChannels: NotificationChannelWith[]
organizations: OrganizationWithMembers[]
}
export const NotificationChannelsSection = ({
organizations,
notificationChannels
}: NotificationChannelsSectionProps) => {
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const hasNotifiers = notificationChannels.length > 0;
return (
<div className="h-full">
<NotifierAddEditModal open={isAddModalOpen} onOpenChangeAction={setIsAddModalOpen} adminView={false} trigger={false}/>
{hasNotifiers ? (
<div className="h-full">
<CardsWithPagination
data={notificationChannels}
cardItem={NotifierCard}
cardsPerPage={8}
numberOfColumns={2}
adminView={true}
organizations={organizations}
/>
</div>
) : (
<EmptyStatePlaceholder
text="No notification channels configured yet"
onClick={() => {
setIsAddModalOpen(true)
}}
className="h-full"
/>
)}
</div>
);
}
@@ -0,0 +1,115 @@
"use client";
import {useRouter} from "next/navigation";
import {useMutation} from "@tanstack/react-query";
import {Form, FormControl, FormField, FormItem, useZodForm} from "@/components/ui/form";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {
NotificationChannelsOrganizationSchema,
NotificationChannelsOrganizationType
} from "@/components/wrappers/dashboard/admin/notifications/channels/organization/notification-channels-organization.schema";
import {MultiSelect} from "@/components/wrappers/common/multiselect/multi-select";
import {
updateNotificationChannelsOrganizationAction
} from "@/components/wrappers/dashboard/admin/notifications/channels/organization/notification-channels-organization.action";
import {toast} from "sonner";
type NotifierChannelOrganisationFormProps = {
organizations?: OrganizationWithMembers[];
defaultValues?: NotificationChannelWith
};
export const NotifierChannelOrganisationForm = ({
organizations,
defaultValues,
}: NotifierChannelOrganisationFormProps) => {
const router = useRouter();
console.log("defaultValues", defaultValues);
console.log("organizations", organizations);
const defaultOrganizationIds = defaultValues?.organizations?.map(organization => organization.organizationId) ?? []
const form = useZodForm({
schema: NotificationChannelsOrganizationSchema,
// @ts-ignore
defaultValues: {
organizations: defaultOrganizationIds
},
});
const formatOrganizationsList = (organizations: OrganizationWithMembers[]) => {
return organizations
.map((organization) => ({
value: organization.id,
label: `${organization.name}`,
}));
};
const mutationUpdateNotificationChannelOrganizations = useMutation({
mutationFn: async (values: NotificationChannelsOrganizationType) => {
const payload = {
data: values.organizations,
notificationChannelId: defaultValues?.id ?? ""
};
const result = await updateNotificationChannelsOrganizationAction(payload)
const inner = result?.data;
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
router.refresh();
} else {
toast.error(inner?.actionError?.message);
}
}
});
return (
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutationUpdateNotificationChannelOrganizations.mutateAsync(values);
}}
>
<FormField
control={form.control}
name={`organizations`}
render={({field}) => (
<FormItem>
<FormControl>
<MultiSelect
options={formatOrganizationsList(organizations ?? [])}
onValueChange={field.onChange}
defaultValue={field.value ?? []}
placeholder="Select organization(s)"
variant="inverted"
animation={0}
/>
</FormControl>
</FormItem>
)}
/>
<div className="flex justify-end">
<div className="flex gap-2 justify-end">
<ButtonWithLoading isPending={mutationUpdateNotificationChannelOrganizations.isPending}>
Save
</ButtonWithLoading>
</div>
</div>
</Form>
);
};
@@ -0,0 +1,79 @@
"use server"
import {userAction} from "@/lib/safe-actions/actions";
import {z} from "zod";
import {ServerActionResult} from "@/types/action-type";
import {db} from "@/db";
import {and, eq, inArray} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
export const updateNotificationChannelsOrganizationAction = userAction
.schema(
z.object({
data: z.array(z.string()),
notificationChannelId: z.string(),
})
)
.action(async ({parsedInput, ctx}): Promise<ServerActionResult<null>> => {
try {
const organizationsIds = parsedInput.data;
const notificationChannel = await db.query.notificationChannel.findFirst({
where: eq(drizzleDb.schemas.notificationChannel.id, parsedInput.notificationChannelId),
with: {
organizations: true,
}
}) as NotificationChannelWith;
if (!notificationChannel) {
return {
success: false,
actionError: {
message: "Notification channel not found.",
status: 404,
cause: "not_found",
},
};
}
const existingItemIds = notificationChannel.organizations.map((organization) => organization.organizationId);
const organizationsToAdd = organizationsIds.filter((id) => !existingItemIds.includes(id));
const organizationsToRemove = existingItemIds.filter((id) => !organizationsIds.includes(id));
if (organizationsToAdd.length > 0) {
for (const organizationToAdd of organizationsToAdd) {
await db.insert(drizzleDb.schemas.organizationNotificationChannel).values({
organizationId: organizationToAdd,
notificationChannelId: parsedInput.notificationChannelId
});
}
}
if (organizationsToRemove.length > 0) {
await db.delete(drizzleDb.schemas.organizationNotificationChannel).where(and(inArray(drizzleDb.schemas.organizationNotificationChannel.organizationId, organizationsToRemove), eq(drizzleDb.schemas.organizationNotificationChannel.notificationChannelId, parsedInput.notificationChannelId))).execute();
}
return {
success: true,
value: null,
actionSuccess: {
message: "Notification channel organizations has been successfully updated.",
messageParams: {notificationChannelId: parsedInput.notificationChannelId},
},
};
} catch (error) {
console.error("Error updating notification channel:", error);
return {
success: false,
actionError: {
message: "Failed to update notification channel.",
status: 500,
cause: "server_error",
messageParams: {message: "Error updating the notification channel"},
},
};
}
});
@@ -0,0 +1,7 @@
import {z} from "zod";
export const NotificationChannelsOrganizationSchema = z.object({
organizations: z.array(z.string())
});
export type NotificationChannelsOrganizationType = z.infer<typeof NotificationChannelsOrganizationSchema>;
@@ -0,0 +1,13 @@
import {Mail, MessageSquare, Webhook} from "lucide-react";
export const getNotificationChannelIcon = (type: string) => {
const Icon = notificationTypes.find((t) => t.value === type)?.icon
return Icon ? <Icon className="h-4 w-4"/> : null
}
const notificationTypes = [
{value: "smtp", label: "Email", icon: Mail},
{value: "slack", label: "Slack", icon: MessageSquare},
{value: "webhook", label: "Webhook", icon: Webhook},
]
@@ -0,0 +1,110 @@
"use client";
import {ColumnDef} from "@tanstack/react-table";
import {NotificationLogWithRelations} from "@/db/services/notification-log";
import {getNotificationChannelIcon} from "@/components/wrappers/dashboard/admin/notifications/helpers";
import {humanReadableDate} from "@/utils/date-formatting";
import {CheckCircle2, XCircle} from "lucide-react";
import {Badge} from "@/components/ui/badge";
import {NotificationLogModal} from "@/components/wrappers/dashboard/admin/notifications/logs/notification-log-modal";
export function notificationLogsColumns(): ColumnDef<NotificationLogWithRelations>[] {
return [
{
accessorKey: "success",
header: "Status",
cell: ({row}) => {
const status = row.original.success ? "delivered" : "failed";
return(
<Badge variant="outline" className={`gap-1.5 ${getStatusColor(status)}`}>
{getStatusIcon(row.original.success)}
<span className="capitalize">{status}</span>
</Badge>
)
}
},
{
accessorKey: "channel",
header: "Channel",
cell: ({row}) => {
const channel = row.original.channel
return (
<div className="flex items-center gap-2">
<div
className="flex h-10 w-10 items-center justify-center rounded-md bg-secondary border border-border">
{getNotificationChannelIcon(channel?.provider ?? "")}
</div>
{channel?.name}
</div>
)
}
},
{
accessorKey: "sentAt",
header: "Sent At",
cell: ({row}) => {
const sentAt = humanReadableDate(row.original.sentAt)
return (
<div>
{sentAt}
</div>
)
}
},
{
accessorKey: "policy",
header: "Event Kind",
cell: ({ row }) => {
const eventKind = row.original.policy?.event;
if (!eventKind) {
return (
<div className="text-muted-foreground italic">
No event
</div>
);
}
return (
<div className="flex items-center gap-2">
<Badge>{eventKind}</Badge>
</div>
);
}
},
{
accessorKey: "details",
header: "Details",
cell: ({row}) => {
return (
<div>
<NotificationLogModal notificationLog={row.original} />
</div>
);
}
}
];
}
const getStatusIcon = (status: boolean) => {
switch (status) {
case true:
return <CheckCircle2 className="h-4 w-4"/>
case false:
return <XCircle className="h-4 w-4"/>
}
}
const getStatusColor = (status: string) => {
switch (status) {
case "delivered":
return "bg-green-100 dark:bg-green-100/10"
case "failed":
return "bg-red-100 dark:bg-red-100/10"
case "pending":
return "bg-orange-100 dark:bg-orange-100/10"
}
}
@@ -0,0 +1,62 @@
"use client"
import {Eye} from "lucide-react";
import {useState} from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger
} from "@/components/ui/dialog";
import {Button} from "@/components/ui/button";
import {Separator} from "@/components/ui/separator";
import {NotificationLogWithRelations} from "@/db/services/notification-log";
type NotificationLogModalProps = {
notificationLog: NotificationLogWithRelations;
}
export const NotificationLogModal = ({notificationLog}: NotificationLogModalProps) => {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" onClick={() => setOpen(true)} className="relative">
<Eye/>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Notification details</DialogTitle>
<DialogDescription>
Details of the notification sent
</DialogDescription>
<Separator className="mt-3 mb-3"/>
<div className="space-y-2 mb-4">
<div>
<span className="font-semibold">Title:</span>{" "}
<span>{notificationLog.content.title}</span>
</div>
<div>
<span className="font-semibold">Message:</span>{" "}
<span>{notificationLog.content.message}</span>
</div>
</div>
{notificationLog.payload && (
<>
<Separator className="mb-3" />
<div className="text-sm font-mono bg-gray-100 dark:bg-gray-800 dark:text-gray-200 p-4 rounded-md overflow-auto">
{JSON.stringify(notificationLog.payload, null, 2)}
</div>
</>
)}
</DialogHeader>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,25 @@
"use client"
import {DataTable} from "@/components/wrappers/common/table/data-table";
import {useRouter} from "next/navigation";
import {
notificationLogsColumns,
} from "@/components/wrappers/dashboard/admin/notifications/logs/columns";
import {NotificationLogWithRelations} from "@/db/services/notification-log";
type NotificationsLogsListProps = {
notificationLogs: NotificationLogWithRelations[]
}
export const NotificationLogsList = (props: NotificationsLogsListProps) => {
const router = useRouter();
return (
<DataTable
enableSelect={false}
columns={notificationLogsColumns()}
data={props.notificationLogs}
enablePagination
/>
)
}
@@ -1,38 +0,0 @@
"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 {AdminOrganizationForm} from "@/components/wrappers/dashboard/admin/organization/admin-organization-form";
type AdminOrganizationAddModalProps = {}
export const AdminOrganizationAddModal = (props: AdminOrganizationAddModalProps) => {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<Plus/> add
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>add organization</DialogTitle>
<DialogDescription>
your description
</DialogDescription>
<AdminOrganizationForm onSuccess={() => setOpen(false)}/>
</DialogHeader>
</DialogContent>
</Dialog>
)
}
@@ -1,7 +1,7 @@
"use client"
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {AdminOrganizationList} from "@/components/wrappers/dashboard/admin/organization/admin-orgnization-list";
import {AdminOrganizationList} from "@/components/wrappers/dashboard/admin/organizations/organization/admin-orgnization-list";
export type AdminOrganizationsTableProps = {
organizations: OrganizationWithMembers[];
@@ -11,13 +11,13 @@ export type AdminOrganizationsTableProps = {
export const AdminOrganizationsTable = (props: AdminOrganizationsTableProps) => {
const {organizations} = props;
return (
<div className="flex flex-col gap-y-4 h-full py-4">
<Card>
<div className="flex flex-col gap-y-4 h-full py-4 ">
<Card className="h-full">
<CardHeader>
<CardTitle>Active organizations</CardTitle>
<CardDescription>Manage all system organizations</CardDescription>
</CardHeader>
<CardContent>
<CardContent className="h-full">
<AdminOrganizationList organizations={organizations} />
</CardContent>
</Card>
@@ -0,0 +1,27 @@
"use client"
import {ColumnDef} from "@tanstack/react-table";
import {Organization} from "@/db/schema/03_organization";
export const organizationsColumnsAdmin: ColumnDef<Organization>[] = [
{
accessorKey: "name",
header: "Name",
},
// {
// header: "Action",
// id: "actions",
// cell: ({row}) => {
// const router = useRouter();
// const {data: session, isPending} = useSession();
// const isSuperAdmin = session?.user.role == "superadmin";
//
// return (
// <ButtonDeleteUser
// disabled={!isSuperAdmin || !session || session?.user.email === row.original.email}
// userId={row.original.id}/>
// );
// },
// },
];
@@ -0,0 +1,49 @@
"use client"
import {CreateOrganizationModal} from "@/components/wrappers/dashboard/organization/create-organisation-modal";
import {useRouter} from "next/navigation";
import {authClient} from "@/lib/auth/auth-client";
import {useState} from "react";
import {Button} from "@/components/ui/button";
import {Plus} from "lucide-react";
type AdminOrganizationAddModalProps = {}
export const AdminOrganizationAddModal = (props: AdminOrganizationAddModalProps) => {
const router = useRouter();
const {data: organizations, refetch} = authClient.useListOrganizations();
const {data: activeOrganization, refetch: refetchActiveOrga} = authClient.useActiveOrganization();
const [openModal, setOpenModal] = useState(false);
if (!organizations) return null;
const handleReload = () => {
refetch();
refetchActiveOrga();
router.refresh();
};
const handleOpen = () => {
setOpenModal(true);
}
return (
<>
<Button onClick={handleOpen}>
<Plus/> Create a new organization
</Button>
<CreateOrganizationModal
redirect={"/dashboard/admin/organizations"}
open={openModal}
onSuccess={handleReload}
onOpenChange={setOpenModal}
/>
</>
)
}

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