mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c0c462192 | ||
|
|
ef7570db76 | ||
|
|
e1bc1ec6c8 | ||
|
|
ca232250cc | ||
|
|
fde7688485 | ||
|
|
9458b12d69 | ||
|
|
5cf6cf075e | ||
|
|
8980e6d4a8 | ||
|
|
ac951378e8 | ||
|
|
934e635cdb | ||
|
|
d968aa9c16 | ||
|
|
eb471c7cdb | ||
|
|
e886bdd58d | ||
|
|
fe5d8bae1b | ||
|
|
aabdea06a9 | ||
|
|
37a3ace161 | ||
|
|
933ab7d68a | ||
|
|
f42cb37d67 | ||
|
|
7dfd9f02e8 | ||
|
|
411e09f452 | ||
|
|
f21149f42e | ||
|
|
099b6fb0e8 | ||
|
|
eb2e0ef468 | ||
|
|
4644ed2f41 | ||
|
|
cf8e5b7094 | ||
|
|
b0775e82ea | ||
|
|
f9b3461a44 |
Binary file not shown.
@@ -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>
|
||||
|
||||

|
||||
|
||||
<h3 align="center">Portabase</h3>
|
||||
<p>
|
||||
Free, open-source, and self-hosted solution for automated backup and restoration of your database instances.
|
||||
</p>
|
||||
|
||||
[](LICENSE)
|
||||
[](https://hub.docker.com/r/solucetechnologies/portabase)
|
||||
[](https://github.com/RostislavDugin/postgresus)
|
||||
|
||||
[](https://www.postgresql.org/)
|
||||
[](https://www.mysql.com/)
|
||||
[](https://mariadb.org/)
|
||||
[](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>
|
||||
|
||||

|
||||
|
||||
</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/
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Metadata} from "next";
|
||||
import {ForgotPasswordForm} from "@/components/wrappers/auth/forgot-password/forgot-password-form";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Forgot Password",
|
||||
};
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
return (
|
||||
<div className="mx-auto grid w-full gap-6">
|
||||
<ForgotPasswordForm/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import {notFound} from "next/navigation";
|
||||
import {env} from "@/env.mjs";
|
||||
import {LoginForm} from "@/components/wrappers/auth/login/login-form/login-form";
|
||||
import {Metadata} from "next";
|
||||
@@ -9,12 +8,10 @@ export const metadata: Metadata = {
|
||||
|
||||
export default async function SignInPage() {
|
||||
const authGoogleEnabled = env.AUTH_GOOGLE_METHOD;
|
||||
if (!authGoogleEnabled) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto grid w-full gap-6">
|
||||
<LoginForm authGoogleEnabled={authGoogleEnabled}/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -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,45 +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";
|
||||
import {Metadata} from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Admin",
|
||||
};
|
||||
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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+2
-2
@@ -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>
|
||||
);
|
||||
+11
-3
@@ -14,6 +14,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 +39,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,6 +85,9 @@ 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";
|
||||
@@ -101,6 +107,7 @@ export default async function RoutePage(props: PageParams<{
|
||||
{/*<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}/>
|
||||
@@ -114,9 +121,10 @@ export default async function RoutePage(props: PageParams<{
|
||||
<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>
|
||||
|
||||
@@ -7,10 +7,9 @@ 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";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Settings",
|
||||
@@ -25,6 +24,8 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const notificationChannels = await getOrganizationChannels(organization.id)
|
||||
|
||||
const isMember = activeMember?.role === "member";
|
||||
const isOwner = activeMember?.role === "owner";
|
||||
|
||||
@@ -44,7 +45,10 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
||||
</PageActions>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<SettingsOrganizationMembersTable organization={organization}/>
|
||||
<OrganizationTabs
|
||||
organization={organization}
|
||||
notificationChannels={notificationChannels}
|
||||
/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
)
|
||||
|
||||
@@ -3,7 +3,6 @@ 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";
|
||||
@@ -26,6 +25,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
const sessions = await getSessions();
|
||||
const accounts = await getAccounts();
|
||||
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="justify-between gap-2 sm:flex">
|
||||
@@ -48,7 +48,8 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
<PageContent>
|
||||
<UserForm
|
||||
userId={user.id}
|
||||
sessions={sessions} accounts={accounts}
|
||||
sessions={sessions}
|
||||
accounts={accounts}
|
||||
defaultValues={{
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -64,10 +64,12 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
}
|
||||
} else {
|
||||
|
||||
|
||||
const [databaseUpdated] = await dbClient
|
||||
.update(drizzleDb.schemas.database)
|
||||
.set({lastContact: lastContact})
|
||||
.set({
|
||||
agentId: agent.id,
|
||||
lastContact: lastContact
|
||||
})
|
||||
.where(eq(drizzleDb.schemas.database.id, existingDatabase.id))
|
||||
.returning();
|
||||
|
||||
|
||||
+5
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-14
@@ -10,6 +10,8 @@ services:
|
||||
image: solucetechnologies/portabase:1.1.3-rc.3
|
||||
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
@@ -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:
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [ -n "$TZ" ]; then
|
||||
if [ -f "/usr/share/zoneinfo/$TZ" ]; then
|
||||
ln -sf /usr/share/zoneinfo/$TZ /etc/localtime
|
||||
echo "$TZ" > /etc/timezone
|
||||
echo "[INFO] Timezone set to $TZ"
|
||||
else
|
||||
echo "[WARN] Timezone '$TZ' not found. Using default."
|
||||
fi
|
||||
fi
|
||||
|
||||
node server.js
|
||||
|
||||
exec "$@"
|
||||
@@ -1,6 +0,0 @@
|
||||
import { Button as ReactEmailButton } from "@react-email/components";
|
||||
import { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export const Button = (props: ComponentPropsWithoutRef<typeof ReactEmailButton>) => {
|
||||
return <ReactEmailButton className="block w-52 rounded bg-blue-600 py-3.5 text-center text-sm font-normal text-white no-underline " {...props} />;
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Button } from "@react-email/button";
|
||||
import { Html } from "@react-email/html";
|
||||
import * as React from "react";
|
||||
|
||||
export default function HelloEmail() {
|
||||
return (
|
||||
<Html>
|
||||
<Button href="https://example.com" style={{ background: "#000", color: "#fff" }}>
|
||||
Hello,
|
||||
</Button>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Text } from "@react-email/components";
|
||||
import * as React from "react";
|
||||
import EmailLayout from "./EmailLayout";
|
||||
import { Button } from "@react-email/button";
|
||||
|
||||
export const TestEmailSettings = () => {
|
||||
return (
|
||||
<EmailLayout preview="Email Setup">
|
||||
<Text className="text-base font-light leading-8 text-green-800 ">Hi, your email settings are setup !</Text>
|
||||
<Text className="text-base font-light leading-8 text-green-800 ">Best regard,</Text>{" "}
|
||||
<Text className="text-base font-light leading-8 text-green-800 ">Portabase</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default TestEmailSettings;
|
||||
@@ -1,6 +0,0 @@
|
||||
import { Text as ReactEmailText } from "@react-email/components";
|
||||
import { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export const Text = (props: ComponentPropsWithoutRef<typeof ReactEmailText>) => {
|
||||
return <ReactEmailText className="text-base font-light leading-8 text-green-800 " {...props} />;
|
||||
};
|
||||
@@ -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/**',
|
||||
|
||||
+9
-5
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "portabase",
|
||||
"version": "1.1.3",
|
||||
"version": "1.1.4",
|
||||
"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",
|
||||
@@ -52,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.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -63,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.7",
|
||||
"next-safe-action": "^7.10.8",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-cron": "^4.2.1",
|
||||
@@ -81,6 +82,7 @@
|
||||
"react-hook-form": "^7.56.3",
|
||||
"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",
|
||||
@@ -93,6 +95,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",
|
||||
@@ -102,6 +105,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",
|
||||
|
||||
@@ -43,6 +43,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'"],
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,6 @@
|
||||
import {Text as ReactEmailText} from "@react-email/components";
|
||||
import {ComponentPropsWithoutRef} from "react";
|
||||
|
||||
export const EmailText = (props: ComponentPropsWithoutRef<typeof ReactEmailText>) => {
|
||||
return <ReactEmailText className="text-base font-light leading-8 text-green-800 " {...props} />;
|
||||
};
|
||||
@@ -7,117 +7,118 @@ import { XIcon } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
"max-h-[90vh] overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,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,164 @@
|
||||
"use client";
|
||||
|
||||
import {Ref, 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;
|
||||
}
|
||||
|
||||
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,
|
||||
}: 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;
|
||||
};
|
||||
|
||||
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}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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,113 @@
|
||||
"use client";
|
||||
|
||||
import {Card, CardContent, CardHeader} from "@/components/ui/card";
|
||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Form} from "@/components/ui/form";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {toast} from "sonner";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
||||
import Link from "next/link";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
import {LoginSchema, LoginType} from "@/components/wrappers/auth/login/login-form/login-form.schema";
|
||||
import {SocialAuthButton, SocialProviderType} from "@/components/wrappers/auth/login/button-auth/social-auth-button";
|
||||
import {authClient, signIn} from "@/lib/auth/auth-client";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {Icon} from "@iconify/react";
|
||||
import {useEffect, useState} from "react";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {
|
||||
ForgotPasswordSchema,
|
||||
ForgotPasswordType
|
||||
} from "@/components/wrappers/auth/forgot-password/forgot-password.schema";
|
||||
import {ArrowLeft} from "lucide-react";
|
||||
import {getServerUrl} from "@/utils/get-server-url";
|
||||
|
||||
export type ForgotPasswordFormProps = {};
|
||||
|
||||
export const ForgotPasswordForm = (props: ForgotPasswordFormProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const form = useZodForm({
|
||||
schema: ForgotPasswordSchema,
|
||||
});
|
||||
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: ForgotPasswordType) => {
|
||||
try {
|
||||
const {data, error} = await authClient.requestPasswordReset({
|
||||
email: values.email,
|
||||
redirectTo: `${getServerUrl()}/reset-password`,
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
// @ts-ignore
|
||||
toast.success(data.message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error("Unexpected client error during login");
|
||||
}
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.message || "Client error");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="grid gap-2 text-center mb-2">
|
||||
<h1 className="text-3xl font-bold">Forgot Password</h1>
|
||||
<p className="text-balance text-muted-foreground">
|
||||
Enter your email to reset your password
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={async (values) => {
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
defaultValue=""
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="email"
|
||||
placeholder="example@portabase.io"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
Send reset link
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<div className="mt-4 text-center text-sm flex items-center justify-center gap-1">
|
||||
<ArrowLeft size={14} className="text-gray-400"/>
|
||||
<Link href="/login" className="underline">
|
||||
Go back
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ForgotPasswordSchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.min(1, "Email is required")
|
||||
.email("Invalid email address"),
|
||||
});
|
||||
|
||||
export type ForgotPasswordType = z.infer<typeof ForgotPasswordSchema>;
|
||||
@@ -1,8 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const LoginSchema = z.object({
|
||||
email: z.string(),
|
||||
password: z.string(),
|
||||
});
|
||||
email: z.string().email({message: "Email is invalid"}),
|
||||
password: z.string().nonempty({message: "Password could not be empty"}),
|
||||
|
||||
})
|
||||
|
||||
export type LoginType = z.infer<typeof LoginSchema>;
|
||||
|
||||
@@ -1,150 +1,3 @@
|
||||
// "use client";
|
||||
//
|
||||
// import {Card, CardContent, CardHeader} from "@/components/ui/card";
|
||||
// import {FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
|
||||
// import {Input} from "@/components/ui/input";
|
||||
// import {Form} from "@/components/ui/form";
|
||||
// import {Button} from "@/components/ui/button";
|
||||
// import {toast} from "sonner";
|
||||
// import {useMutation} from "@tanstack/react-query";
|
||||
// import {TooltipProvider} from "@/components/ui/tooltip";
|
||||
// import Link from "next/link";
|
||||
// import {PasswordInput} from "@/components/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 {useRouter} from "next/navigation";
|
||||
// import {Icon} from "@iconify/react";
|
||||
// import {useEffect, useState} from "react";
|
||||
//
|
||||
// export type loginFormProps = {
|
||||
// defaultValues?: LoginType;
|
||||
// authGoogleEnabled: boolean;
|
||||
//
|
||||
// };
|
||||
//
|
||||
// export const LoginForm = (props: loginFormProps) => {
|
||||
// const router = useRouter();
|
||||
//
|
||||
// const form = useZodForm({
|
||||
// schema: LoginSchema,
|
||||
// });
|
||||
//
|
||||
// const [urlParams, setUrlParams] = useState<URLSearchParams>();
|
||||
//
|
||||
// useEffect(() => {
|
||||
// const urlParams = new URLSearchParams(window.location.search);
|
||||
// console.log(urlParams);
|
||||
// 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());
|
||||
// }
|
||||
// }, []);
|
||||
//
|
||||
//
|
||||
// const mutation = useMutation({
|
||||
// mutationFn: async (values: LoginType) => {
|
||||
// const {error} = await signIn.email(
|
||||
// {
|
||||
// password: values.password,
|
||||
// email: values.email,
|
||||
// callbackURL: urlParams?.get("redirect") ?? "/dashboard/profile",
|
||||
// }, {
|
||||
// onSuccess: () => {
|
||||
// toast.success("Login success");
|
||||
// },
|
||||
// });
|
||||
// if (error) {
|
||||
// toast.error(error.message);
|
||||
// }
|
||||
// },
|
||||
// });
|
||||
//
|
||||
// const availableProviders: SocialProviderType[] = [];
|
||||
//
|
||||
// if (props.authGoogleEnabled) {
|
||||
// availableProviders.push(
|
||||
// {
|
||||
// id: "google",
|
||||
// name: "Google",
|
||||
// icon: <Icon icon={"logos:google-icon"} width="25" height="25"/>,
|
||||
// },
|
||||
// )
|
||||
// }
|
||||
//
|
||||
//
|
||||
// return (
|
||||
// <TooltipProvider>
|
||||
// <Card>
|
||||
// <CardHeader>
|
||||
// <div className="grid gap-2 text-center mb-2">
|
||||
// <h1 className="text-3xl font-bold">Login</h1>
|
||||
// <p className="text-balance text-muted-foreground">Enter your 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't have an account?{" "}
|
||||
// <Link href={"/register"} className="underline">
|
||||
// Sign up
|
||||
// </Link>
|
||||
// </div>
|
||||
// </Form>
|
||||
// <SocialAuthButton
|
||||
// callBackURL={urlParams?.get("redirect") ?? "/dashboard/profile"}
|
||||
// providers={availableProviders}/>
|
||||
// </CardContent>
|
||||
// </Card>
|
||||
// </TooltipProvider>
|
||||
// );
|
||||
// };
|
||||
"use client";
|
||||
|
||||
import {Card, CardContent, CardHeader} from "@/components/ui/card";
|
||||
@@ -156,13 +9,14 @@ 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 {PasswordInput} from "@/components/ui/password-input";
|
||||
import {LoginSchema, LoginType} from "@/components/wrappers/auth/login/login-form/login-form.schema";
|
||||
import {SocialAuthButton, SocialProviderType} from "@/components/wrappers/auth/login/button-auth/social-auth-button";
|
||||
import {signIn} from "@/lib/auth/auth-client";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {Icon} from "@iconify/react";
|
||||
import {useEffect, useState} from "react";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
|
||||
export type loginFormProps = {
|
||||
defaultValues?: LoginType;
|
||||
@@ -271,9 +125,14 @@ export const LoginForm = (props: loginFormProps) => {
|
||||
defaultValue=""
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Password</FormLabel>
|
||||
{/* Optional forgot password link */}
|
||||
<div className="text-center text-sm">
|
||||
<Link href="/forgot-password" className="hover:underline">
|
||||
Forgot your password ?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
@@ -289,18 +148,26 @@ export const LoginForm = (props: loginFormProps) => {
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Signing in..." : "Sign in"}
|
||||
</Button>
|
||||
<div className="mt-4 text-center text-sm">
|
||||
Don't have an account?{" "}
|
||||
<Link href="/register" className="underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<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>
|
||||
|
||||
<SocialAuthButton
|
||||
callBackURL={urlParams.get("redirect") ?? "/dashboard/profile"}
|
||||
providers={availableProviders}
|
||||
/>
|
||||
|
||||
<div className="mt-4 text-center text-sm">
|
||||
Don't have an account ?{" "}
|
||||
<Link href="/register" className="underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -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}/>
|
||||
)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}/>;
|
||||
};
|
||||
|
||||
@@ -1,31 +1,72 @@
|
||||
// import Link from "next/link";
|
||||
// import {cn} from "@/lib/utils";
|
||||
// import {Plus} from "lucide-react";
|
||||
//
|
||||
// type EmptyStatePlaceholderProps = {
|
||||
// url?: string;
|
||||
// text: string;
|
||||
// className?: string;
|
||||
// }
|
||||
//
|
||||
// export const EmptyStatePlaceholder = ({url, text, className}: EmptyStatePlaceholderProps) => {
|
||||
// return (
|
||||
// <div className={cn("",className)}>{url ?
|
||||
// <Link
|
||||
// href={url}
|
||||
// className={cn(
|
||||
// "h-full",
|
||||
// "flex flex-col items-center justify-center w-full rounded-2xl border border-dashed border-muted p-6 lg:p-10",
|
||||
// "hover:bg-muted/50 transition-colors text-muted-foreground hover:text-primary text-center space-y-2"
|
||||
// )}
|
||||
// >
|
||||
// <Plus className="w-5 h-5 lg:w-6 lg:h-6"/>
|
||||
// <span className="text-sm lg:text-base font-medium">{text}</span>
|
||||
// </Link>
|
||||
// :
|
||||
// <div className="flex h-full flex-col items-center justify-center py-12 text-center">
|
||||
// <p className="text-lg text-muted-foreground">{text}</p>
|
||||
// </div>
|
||||
// }
|
||||
// </div>
|
||||
//
|
||||
// )
|
||||
// }
|
||||
import Link from "next/link";
|
||||
import {cn} from "@/lib/utils";
|
||||
import {Plus} from "lucide-react";
|
||||
|
||||
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 content = (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full flex-col items-center justify-center w-full rounded-2xl border border-dashed border-muted p-6 lg:p-10",
|
||||
"hover:bg-muted/50 transition-colors text-muted-foreground hover:text-primary text-center space-y-4 cursor-pointer",
|
||||
onClick || url ? "cursor-pointer" : "cursor-default"
|
||||
)}
|
||||
{...(onClick ? {onClick} : url ? {asChild: true} : {})}
|
||||
>
|
||||
<Plus className="w-5 h-5 lg:w-6 lg:h-6"/>
|
||||
<div>
|
||||
<p className="text-sm ">{text}</p>
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className={cn("", className)}>
|
||||
{url ? <Link href={url}>{content}</Link> : content}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
+50
@@ -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>
|
||||
);
|
||||
}
|
||||
+115
@@ -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>
|
||||
);
|
||||
};
|
||||
+79
@@ -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"},
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
+7
@@ -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>
|
||||
)
|
||||
}
|
||||
+4
-4
@@ -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>
|
||||
-2
@@ -11,9 +11,7 @@ import {deleteUserAction} from "@/components/wrappers/dashboard/profile/button-d
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {UserWithAccounts} from "@/db/schema/02_user";
|
||||
import {authClient, useSession} from "@/lib/auth/auth-client";
|
||||
import {formatFrenchDate} from "@/utils/date-formatting";
|
||||
import {providerSwitch} from "@/components/wrappers/common/provider-switch";
|
||||
import {ButtonDeleteUser} from "@/components/wrappers/dashboard/admin/tabs/admin-user-tab/button-delete-use";
|
||||
import {Organization} from "@/db/schema/03_organization";
|
||||
|
||||
export const organizationsColumnsAdmin: ColumnDef<Organization>[] = [
|
||||
+49
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form";
|
||||
import { OrganizationSchema } from "@/components/wrappers/dashboard/admin/organization/organization.schema";
|
||||
import { OrganizationSchema } from "@/components/wrappers/dashboard/admin/organizations/organization/organization.schema";
|
||||
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth/auth-client";
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { AdminOrganizationList } from "@/components/wrappers/dashboard/admin/organization/admin-orgnization-list";
|
||||
import { AdminOrganizationAddModal } from "@/components/wrappers/dashboard/admin/organization/admin-organization-add-modal";
|
||||
import { AdminOrganizationList } from "@/components/wrappers/dashboard/admin/organizations/organization/admin-orgnization-list";
|
||||
import { AdminOrganizationAddModal } from "@/components/wrappers/dashboard/admin/organizations/organization/admin-organization-add-modal";
|
||||
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||
|
||||
type AdminOrganizationSectionProps = {
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
import { DataTable } from "@/components/wrappers/common/table/data-table";
|
||||
import { organizationsListColumns } from "@/components/wrappers/dashboard/admin/organization/table-colums";
|
||||
import { organizationsListColumns } from "@/components/wrappers/dashboard/admin/organizations/organization/table-colums";
|
||||
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||
|
||||
type AdminOrganizationListProps = {
|
||||
+2
-2
@@ -7,11 +7,11 @@ import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodFo
|
||||
import {
|
||||
AddMemberSchema,
|
||||
AddMemberSchemaType
|
||||
} from "@/components/wrappers/dashboard/admin/organization/organization.schema";
|
||||
} from "@/components/wrappers/dashboard/admin/organizations/organization/organization.schema";
|
||||
import {SearchInput} from "@/components/ui/search-input";
|
||||
import {
|
||||
addMemberOrganizationAction
|
||||
} from "@/components/wrappers/dashboard/admin/organization/details/add-member.action";
|
||||
} from "@/components/wrappers/dashboard/admin/organizations/organization/details/add-member.action";
|
||||
import {toast} from "sonner";
|
||||
import {OrganizationWithMembers, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
|
||||
import {User} from "@/db/schema/02_user";
|
||||
+19
-9
@@ -1,11 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { OrganizationAddMemberForm } from "@/components/wrappers/dashboard/admin/organization/details/organization-add-member-form";
|
||||
import { useState } from "react";
|
||||
import { UserPlus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {OrganizationWithMembers, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
OrganizationAddMemberForm
|
||||
} from "@/components/wrappers/dashboard/admin/organizations/organization/details/organization-add-member-form";
|
||||
import {useState} from "react";
|
||||
import {UserPlus} from "lucide-react";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import { OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
|
||||
import {User} from "@/db/schema/02_user";
|
||||
|
||||
type OrganizationAddMemberModalProps = {
|
||||
@@ -13,13 +22,13 @@ type OrganizationAddMemberModalProps = {
|
||||
organization: OrganizationWithMembersAndUsers;
|
||||
};
|
||||
|
||||
export const OrganizationAddMemberModal = ({ users, organization }: OrganizationAddMemberModalProps) => {
|
||||
export const OrganizationAddMemberModal = ({users, organization}: OrganizationAddMemberModalProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<UserPlus className="w-4 h-4 mr-2" />
|
||||
<UserPlus className="w-4 h-4 mr-2"/>
|
||||
Add member
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
@@ -28,7 +37,8 @@ export const OrganizationAddMemberModal = ({ users, organization }: Organization
|
||||
<DialogTitle>Add member to your organization</DialogTitle>
|
||||
<DialogDescription>Select a user to add to your organization</DialogDescription>
|
||||
</DialogHeader>
|
||||
<OrganizationAddMemberForm users={users} organization={organization} onSuccessAction={() => setOpen(!open)} />
|
||||
<OrganizationAddMemberForm users={users} organization={organization}
|
||||
onSuccessAction={() => setOpen(!open)}/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
+2
-2
@@ -12,12 +12,12 @@ import {Button} from "@/components/ui/button";
|
||||
import {MoreHorizontal, Settings, Trash2} from "lucide-react";
|
||||
import {
|
||||
OrganizationDeleteMemberModal
|
||||
} from "@/components/wrappers/dashboard/admin/organization/details/organization-delete-member-modal";
|
||||
} from "@/components/wrappers/dashboard/admin/organizations/organization/details/organization-delete-member-modal";
|
||||
import {useState} from "react";
|
||||
import {authClient} from "@/lib/auth/auth-client";
|
||||
import {
|
||||
OrganizationMemberChangeRoleModal
|
||||
} from "@/components/wrappers/dashboard/admin/organization/details/organization-member-change-role";
|
||||
} from "@/components/wrappers/dashboard/admin/organizations/organization/details/organization-member-change-role";
|
||||
import {MemberWithUser, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
|
||||
|
||||
type OrganizationMemberCardProps = {
|
||||
+1
-1
@@ -22,7 +22,7 @@ import {updateMemberRoleAction} from "@/components/wrappers/dashboard/settings/u
|
||||
import {RoleSchemaMember} from "@/components/wrappers/dashboard/settings/member.schema";
|
||||
import {
|
||||
updateMemberRoleAdminAction
|
||||
} from "@/components/wrappers/dashboard/admin/organization/details/role-member.action";
|
||||
} from "@/components/wrappers/dashboard/admin/organizations/organization/details/role-member.action";
|
||||
|
||||
type OrganizationMemberChangeRoleModalProps = {
|
||||
open: boolean;
|
||||
+1
-1
@@ -7,7 +7,7 @@ import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodFo
|
||||
import {
|
||||
UpdateOrganizationSchema,
|
||||
UpdateOrganizationSchemaType
|
||||
} from "@/components/wrappers/dashboard/admin/organization/organization.schema";
|
||||
} from "@/components/wrappers/dashboard/admin/organizations/organization/organization.schema";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {authClient} from "@/lib/auth/auth-client";
|
||||
+3
-3
@@ -6,17 +6,17 @@ import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
|
||||
import {MemberWithUser, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
|
||||
import {
|
||||
UpdateOrganizationForm
|
||||
} from "@/components/wrappers/dashboard/admin/organization/details/update-organization-form";
|
||||
} from "@/components/wrappers/dashboard/admin/organizations/organization/details/update-organization-form";
|
||||
import {
|
||||
OrganizationMemberCard
|
||||
} from "@/components/wrappers/dashboard/admin/organization/details/organization-member-card";
|
||||
} from "@/components/wrappers/dashboard/admin/organizations/organization/details/organization-member-card";
|
||||
import {useRouter, useSearchParams} from "next/navigation";
|
||||
import {useEffect, useState} from "react";
|
||||
import {capitalizeFirstLetter} from "@/utils/text";
|
||||
import {User} from "@/db/schema/02_user";
|
||||
import {
|
||||
OrganizationAddMemberModal
|
||||
} from "@/components/wrappers/dashboard/admin/organization/details/organization-add-member-modal";
|
||||
} from "@/components/wrappers/dashboard/admin/organizations/organization/details/organization-add-member-modal";
|
||||
import {cn} from "@/lib/utils";
|
||||
|
||||
type OrganizationManagementProps = {
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
import {ColumnDef} from "@tanstack/react-table";
|
||||
import {ButtonDeleteOrganization} from "@/components/wrappers/dashboard/admin/organization/button-delete-organization";
|
||||
import {ButtonDeleteOrganization} from "@/components/wrappers/dashboard/admin/organizations/organization/button-delete-organization";
|
||||
import Link from "next/link";
|
||||
import {Settings} from "lucide-react";
|
||||
import {buttonVariants} from "@/components/ui/button";
|
||||
@@ -32,7 +32,7 @@ export function organizationsListColumns(): ColumnDef<OrganizationWithMembers>[]
|
||||
<ButtonDeleteOrganization organisationId={row.original.id}/>
|
||||
)}
|
||||
<Link className={buttonVariants({variant: "outline"})}
|
||||
href={`admin/organization/${row.original.id}`}>
|
||||
href={`organizations/${row.original.id}`}>
|
||||
<Settings/>
|
||||
</Link>
|
||||
</div>
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
import { z } from "zod";
|
||||
import { EmailFormSchema } from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/email-form/email-form.schema";
|
||||
import { EmailFormSchema } from "@/components/wrappers/dashboard/admin/settings/email/email-form/email-form.schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
+96
-6
@@ -14,18 +14,23 @@ import {Input} from "@/components/ui/input";
|
||||
import {Form} from "@/components/ui/form";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
|
||||
|
||||
import {
|
||||
EmailFormSchema,
|
||||
EmailFormType
|
||||
} from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/email-form/email-form.schema";
|
||||
import {PasswordInput} from "@/components/wrappers/auth/password-input/password-input";
|
||||
} from "@/components/wrappers/dashboard/admin/settings/email/email-form/email-form.schema";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
import {
|
||||
updateEmailSettingsAction
|
||||
} from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/email-form/email-form.action";
|
||||
} from "@/components/wrappers/dashboard/admin/settings/email/email-form/email-form.action";
|
||||
import {toast} from "sonner";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {sendEmail} from "@/lib/email/email-helper";
|
||||
import {render} from "@react-email/components";
|
||||
import EmailSettingsTest from "@/components/emails/email-settings-test";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {Send} from "lucide-react";
|
||||
|
||||
export type EmailFormProps = {
|
||||
defaultValues?: EmailFormType;
|
||||
@@ -36,6 +41,8 @@ export const EmailForm = (props: EmailFormProps) => {
|
||||
schema: EmailFormSchema,
|
||||
defaultValues: props.defaultValues,
|
||||
});
|
||||
const isDirty = form.formState.isDirty;
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const mutation = useMutation({
|
||||
@@ -47,10 +54,45 @@ export const EmailForm = (props: EmailFormProps) => {
|
||||
return;
|
||||
}
|
||||
toast.success(`Success updating email informations`);
|
||||
form.reset(data);
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
const mutationSendEmailTest = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!props.defaultValues?.smtpUser || !props.defaultValues?.smtpFrom) {
|
||||
toast.error("SMTP is not configured");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const email = await sendEmail({
|
||||
to: props.defaultValues.smtpUser,
|
||||
subject: "Portabase",
|
||||
html: await render(EmailSettingsTest(), {}),
|
||||
from: props.defaultValues.smtpFrom,
|
||||
});
|
||||
|
||||
if (email?.response) {
|
||||
toast.success("Test Email successfully sent!");
|
||||
}
|
||||
|
||||
return email;
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
toast.error(`Email sending failed: ${error.message}`);
|
||||
} else {
|
||||
toast.error("Unknown error while sending test email");
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Card>
|
||||
@@ -139,8 +181,56 @@ export const EmailForm = (props: EmailFormProps) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button>Save</Button>
|
||||
<div className="flex justify-between gap-4">
|
||||
|
||||
{/*{props.defaultValues?.smtpFrom && (*/}
|
||||
{/* <ButtonWithLoading*/}
|
||||
{/* type="button"*/}
|
||||
{/* isPending={mutationSendEmailTest.isPending}*/}
|
||||
{/* onClick={async () => {*/}
|
||||
{/* await mutationSendEmailTest.mutateAsync();*/}
|
||||
{/* }}*/}
|
||||
{/* icon={<Send/>}*/}
|
||||
{/* size="default"*/}
|
||||
{/* className="bg-green-600 hover:bg-green-700 text-white font-medium shadow-sm transition-all"*/}
|
||||
|
||||
{/* >Send email test</ButtonWithLoading>*/}
|
||||
{/*)}*/}
|
||||
|
||||
|
||||
|
||||
{props.defaultValues?.smtpFrom && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<ButtonWithLoading
|
||||
type="button"
|
||||
disabled={isDirty || mutationSendEmailTest.isPending}
|
||||
isPending={mutationSendEmailTest.isPending}
|
||||
onClick={async () => {
|
||||
await mutationSendEmailTest.mutateAsync();
|
||||
}}
|
||||
icon={<Send />}
|
||||
size="default"
|
||||
className="bg-green-600 hover:bg-green-700 text-white font-medium shadow-sm transition-all"
|
||||
|
||||
>
|
||||
Send email test
|
||||
</ButtonWithLoading>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
|
||||
{isDirty && (
|
||||
<TooltipContent>
|
||||
You must save changes before testing the email settings.
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
>Save</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</CardContent>
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client"
|
||||
import {EmailForm} from "@/components/wrappers/dashboard/admin/settings/email/email-form/email-form";
|
||||
import {Setting} from "@/db/schema/01_setting";
|
||||
import {EmailFormType} from "@/components/wrappers/dashboard/admin/settings/email/email-form/email-form.schema";
|
||||
|
||||
|
||||
export type SettingsEmailSectionProps = {
|
||||
settings: Setting;
|
||||
};
|
||||
|
||||
export const SettingsEmailSection = (props: SettingsEmailSectionProps) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col h-full ">
|
||||
<EmailForm defaultValues={props.settings.smtpFrom ? props.settings as EmailFormType : undefined}/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -6,7 +6,7 @@ import {toast} from "sonner";
|
||||
|
||||
export type AdminSettingsTabProps = {};
|
||||
|
||||
export const AdminSettingsTab = (props: AdminSettingsTabProps) => {
|
||||
export const AdminSettingsSection = (props: AdminSettingsTabProps) => {
|
||||
|
||||
const handleDownloadKey = async () => {
|
||||
|
||||
+7
-7
@@ -1,8 +1,9 @@
|
||||
"use client"
|
||||
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert";
|
||||
import {Info, ShieldCheck} from "lucide-react";
|
||||
import {Switch} from "@/components/ui/switch";
|
||||
import {Label} from "@/components/ui/label";
|
||||
import {StorageS3Form} from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/storage-s3/storage-s3-form";
|
||||
import {StorageS3Form} from "@/components/wrappers/dashboard/admin/settings/storage/storage-s3/storage-s3-form";
|
||||
import {useState} from "react";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
@@ -11,15 +12,15 @@ import {toast} from "sonner";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {
|
||||
updateStorageSettingsAction
|
||||
} from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/storage-s3/s3-form.action";
|
||||
} from "@/components/wrappers/dashboard/admin/settings/storage/storage-s3/s3-form.action";
|
||||
import {Setting} from "@/db/schema/01_setting";
|
||||
import {S3FormType} from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/storage-s3/s3-form.schema";
|
||||
import {S3FormType} from "@/components/wrappers/dashboard/admin/settings/storage/storage-s3/s3-form.schema";
|
||||
|
||||
export type SettingsStorageTabProps = {
|
||||
export type SettingsStorageSectionProps = {
|
||||
settings: Setting;
|
||||
};
|
||||
|
||||
export const SettingsStorageTab = (props: SettingsStorageTabProps) => {
|
||||
export const SettingsStorageSection = (props: SettingsStorageSectionProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const mutation = useMutation({
|
||||
@@ -62,8 +63,7 @@ export const SettingsStorageTab = (props: SettingsStorageTabProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full py-4">
|
||||
<h1>Settings for Portabase storage</h1>
|
||||
<div className="flex flex-col h-full">
|
||||
<Alert className="mt-3">
|
||||
<Info className="h-4 w-4"/>
|
||||
<AlertTitle>Informations</AlertTitle>
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { db } from "@/db";
|
||||
import { S3FormSchema, StorageSwitchSchema } from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/storage-s3/s3-form.schema";
|
||||
import { S3FormSchema, StorageSwitchSchema } from "@/components/wrappers/dashboard/admin/settings/storage/storage-s3/s3-form.schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
+3
-3
@@ -8,11 +8,11 @@ import { Button } from "@/components/ui/button";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
import { S3FormSchema, S3FormType } from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/storage-s3/s3-form.schema";
|
||||
import { S3FormSchema, S3FormType } from "@/components/wrappers/dashboard/admin/settings/storage/storage-s3/s3-form.schema";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { updateS3SettingsAction } from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/storage-s3/s3-form.action";
|
||||
import {PasswordInput} from "@/components/wrappers/auth/password-input/password-input";
|
||||
import { updateS3SettingsAction } from "@/components/wrappers/dashboard/admin/settings/storage/storage-s3/s3-form.action";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
|
||||
export type S3FormProps = {
|
||||
defaultValues?: S3FormType;
|
||||
@@ -1,60 +0,0 @@
|
||||
import {EmailForm} from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/email-form/email-form";
|
||||
import {Send} from "lucide-react";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {sendEmail} from "@/utils/email-helper";
|
||||
import {render} from "@react-email/render";
|
||||
import {toast} from "sonner";
|
||||
import {Setting} from "@/db/schema/01_setting";
|
||||
import {EmailFormType} from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/email-form/email-form.schema";
|
||||
import TestEmailSettings from "../../../../../../../emails/TestEmailSettings";
|
||||
|
||||
export type SettingsEmailTabProps = {
|
||||
settings: Setting;
|
||||
};
|
||||
|
||||
export const SettingsEmailTab = (props: SettingsEmailTabProps) => {
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!props.settings.smtpUser || !props.settings.smtpFrom) {
|
||||
toast.error("SMTP is not configured");
|
||||
return;
|
||||
}
|
||||
|
||||
const email = await sendEmail({
|
||||
to: props.settings.smtpUser,
|
||||
subject: "Portabase",
|
||||
html: await render(TestEmailSettings(), {}),
|
||||
from: props.settings.smtpFrom,
|
||||
});
|
||||
if (email.response) {
|
||||
toast.success("Test Email Successfully sent !");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleSendMailTest = async () => {
|
||||
await mutation.mutateAsync();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full py-4">
|
||||
<div className="flex gap-4 h-fit justify-between">
|
||||
<h1>Settings for Portabase email setup</h1>
|
||||
{props.settings.smtpFrom && (
|
||||
<ButtonWithLoading
|
||||
isPending={mutation.isPending}
|
||||
onClick={async () => {
|
||||
await handleSendMailTest();
|
||||
}}
|
||||
icon={<Send/>}
|
||||
size="default"
|
||||
>Send email test</ButtonWithLoading>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<EmailForm defaultValues={props.settings.smtpFrom ? props.settings as EmailFormType : undefined}/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+7
-12
@@ -6,22 +6,17 @@ import {toast} from "sonner";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {unlinkUserProviderAction} from "@/components/wrappers/dashboard/profile/user-form/user-form.action";
|
||||
import {providerSwitch} from "@/components/wrappers/common/provider-switch";
|
||||
import {Account} from "better-auth";
|
||||
|
||||
export const accountsColumns: ColumnDef<{
|
||||
id: string;
|
||||
provider: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
accountId: string;
|
||||
scopes: string[];
|
||||
}>[] = [
|
||||
|
||||
export const accountsColumns: ColumnDef<Account>[] = [
|
||||
{
|
||||
id: "provider",
|
||||
header: "Provider",
|
||||
cell: ({row}) => {
|
||||
return (
|
||||
<div>
|
||||
{providerSwitch(row.original.provider)}
|
||||
{providerSwitch(row.original.providerId)}
|
||||
</div>
|
||||
|
||||
)
|
||||
@@ -36,7 +31,7 @@ export const accountsColumns: ColumnDef<{
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (row.original.provider === "credential") {
|
||||
if (row.original.providerId === "credential") {
|
||||
toast.error(`This provider cannot be unlinked.`);
|
||||
router.refresh();
|
||||
return;
|
||||
@@ -49,7 +44,7 @@ export const accountsColumns: ColumnDef<{
|
||||
}
|
||||
|
||||
const status = await unlinkUserProviderAction({
|
||||
provider: row.original.provider,
|
||||
provider: row.original.providerId,
|
||||
account: row.original.accountId,
|
||||
});
|
||||
|
||||
@@ -66,7 +61,7 @@ export const accountsColumns: ColumnDef<{
|
||||
<div className="flex items-center gap-2">
|
||||
<ButtonWithLoading
|
||||
variant="outline"
|
||||
disabled={row.original.provider === "credential" || table.getRowModel().rows.length <= 1}
|
||||
disabled={row.original.providerId === "credential" || table.getRowModel().rows.length <= 1}
|
||||
icon={<Unlink color="red" size={15}/>}
|
||||
onClick={async () => {
|
||||
await mutation.mutateAsync();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user