Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4447180e9e | ||
|
|
f111946592 | ||
|
|
475e404fae | ||
|
|
07ce5215d4 | ||
|
|
9f4895fd63 | ||
|
|
c18a9da0d3 | ||
|
|
8e3eb32631 | ||
|
|
f0598b3f59 | ||
|
|
011120fdf9 | ||
|
|
ff175e1edd | ||
|
|
3f56008c39 | ||
|
|
5df95bf7c1 | ||
|
|
23d52d0447 | ||
|
|
795a127998 | ||
|
|
fd73d383f9 | ||
|
|
ee285df7b5 | ||
|
|
7f7f8e642f | ||
|
|
d9997ff5e9 | ||
|
|
36adcb323a | ||
|
|
5302060f44 | ||
|
|
d19a46bee6 | ||
|
|
d504866aad | ||
|
|
601dae4909 | ||
|
|
08f2ad0521 | ||
|
|
3b356e33e7 | ||
|
|
57d9b8da1e | ||
|
|
86f50e5741 | ||
|
|
a6e52fd03d | ||
|
|
116229a29e | ||
|
|
428782e8a4 | ||
|
|
b8442803d9 | ||
|
|
82ab2c63d6 | ||
|
|
e953de0e06 | ||
|
|
f6d927f866 | ||
|
|
3773c4b068 | ||
|
|
bb0dbdadae | ||
|
|
dd8aec50eb | ||
|
|
7410aaea38 | ||
|
|
0f9bbbda3c | ||
|
|
25f9bc6e79 | ||
|
|
caf00b7877 | ||
|
|
9c0c462192 | ||
|
|
ef7570db76 | ||
|
|
e1bc1ec6c8 | ||
|
|
ca232250cc | ||
|
|
fde7688485 | ||
|
|
9458b12d69 | ||
|
|
5cf6cf075e | ||
|
|
8980e6d4a8 | ||
|
|
ac951378e8 | ||
|
|
934e635cdb | ||
|
|
d968aa9c16 | ||
|
|
eb471c7cdb | ||
|
|
e886bdd58d | ||
|
|
fe5d8bae1b | ||
|
|
aabdea06a9 | ||
|
|
f42cb37d67 | ||
|
|
7dfd9f02e8 |
@@ -24,5 +24,8 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0 # Fetch full history for gitleaks
|
||||
- uses: gitleaks/gitleaks-action@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
|
||||
with:
|
||||
config-path: .gitleaks.toml # Optional, if you have a custom config
|
||||
@@ -26,7 +26,7 @@ Please take a moment to review this guide. It will help you understand how to co
|
||||
|
||||
2. **Clone the repository**
|
||||
```bash
|
||||
git clone https://github.com/Soluce-Technologies/portabase
|
||||
git clone https://github.com/Portabase/portabase
|
||||
```
|
||||
|
||||
3. **Set up the development environment**
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
Free, open-source, and self-hosted solution for automated backup and restoration of your database instances.
|
||||
</p>
|
||||
|
||||
[](LICENSE)
|
||||
[](LICENSE)
|
||||
[](https://hub.docker.com/r/solucetechnologies/portabase)
|
||||
[](https://github.com/RostislavDugin/postgresus)
|
||||
|
||||
@@ -21,15 +21,16 @@
|
||||
<p>
|
||||
<strong>
|
||||
<a href="https://portabase.io">Documentation</a> •
|
||||
<a href="https://www.youtube.com/watch?v=D9uFrGxLc4s">Demo</a> •
|
||||
<a href="https://www.youtube.com/watch?v=hvLbX5LN1UE">Demo</a> •
|
||||
<a href="#installation">Installation</a> •
|
||||
<a href="#contributing">Contributing</a> •
|
||||
<a href="https://github.com/Soluce-Technologies/portabase/issues/new?labels=bug&template=bug-report---.md">Report Bug</a> •
|
||||
<a href="https://github.com/Soluce-Technologies/portabase/issues/new?labels=enhancement&template=feature-request---.md">Request Feature</a>
|
||||
<a href="https://github.com/Portabase/portabase/issues/new?labels=bug&template=bug-report---.md">Report Bug</a> •
|
||||
<a href="https://github.com/Portabase/portabase/issues/new?labels=enhancement&template=feature-request---.md">Request Feature</a>
|
||||
</strong>
|
||||
</p>
|
||||
|
||||

|
||||

|
||||
|
||||
|
||||
</div>
|
||||
|
||||
@@ -51,7 +52,7 @@
|
||||
**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)
|
||||
GitHub Repository: [Portabase](https://github.com/Portabase/portabase)
|
||||
|
||||
### 🔧 Built With
|
||||
|
||||
@@ -61,7 +62,6 @@ GitHub Repository: [Portabase](https://github.com/Soluce-Technologies/portabase)
|
||||
- [![BetterAuth][BetterAuth]][BetterAuth-url]
|
||||
- [![Docker][Docker]][Docker-url]
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Getting Started
|
||||
@@ -86,7 +86,7 @@ services:
|
||||
ports:
|
||||
- '8887:80'
|
||||
environment:
|
||||
- TIME_ZONE="Europe/Paris"
|
||||
- TZ="Europe/Paris"
|
||||
volumes:
|
||||
- portabase-private:/app/private
|
||||
depends_on:
|
||||
@@ -122,13 +122,13 @@ docker compose up -d
|
||||
```
|
||||
|
||||
If you use reverse proxy like
|
||||
Traefik : [Check this link](https://portabase.io/docs/portabase/advanced-topics/reverse-proxy)
|
||||
Traefik : [Check this link](https://portabase.io/docs/dashboard/advanced/reverse-proxy)
|
||||
|
||||
### Option 2: Locally (Development)
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/Soluce-Technologies/portabase
|
||||
git clone https://github.com/Portabase/portabase
|
||||
cd portabase
|
||||
```
|
||||
2. Start the development environment:
|
||||
@@ -150,7 +150,7 @@ It currently supports:
|
||||
|
||||
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
|
||||
3. **Add your first agent** – Follow [this guide](https://github.com/Portabase/agent-portabase) for setup
|
||||
instructions.
|
||||
4. **Create organizations and projects** – Link your databases to projects to enable backups and restores.
|
||||
5. **Configure backup policies** – Define schedules (hourly, daily, weekly, or monthly) and retention rules.
|
||||
@@ -177,7 +177,7 @@ It currently supports:
|
||||
- [x] MySQL
|
||||
- [x] MariaDB
|
||||
|
||||
Check out [open issues](https://github.com/Soluce-Technologies/portabase/issues) for more.
|
||||
Check out [open issues](https://github.com/Portabase/portabase/issues) for more.
|
||||
|
||||
---
|
||||
|
||||
@@ -204,7 +204,7 @@ Give the project a ⭐ if you like it!
|
||||
|
||||
### Top Contributors
|
||||
|
||||
[](https://github.com/Soluce-Technologies/portabase/graphs/contributors)
|
||||
[](https://github.com/Portabase/portabase/graphs/contributors)
|
||||
|
||||
---
|
||||
|
||||
@@ -279,7 +279,7 @@ Distributed under the Apache License. See `LICENSE.txt` for more details.
|
||||
|
||||
- Killian Larcher - killian.larcher@soluce-technologies.com
|
||||
- Charles Gauthereau - charles.gauthereau@soluce-technologies.com
|
||||
- Project Link: [Portabase GitHub](https://github.com/Soluce-Technologies/portabase)
|
||||
- Project Link: [Portabase GitHub](https://github.com/Portabase/portabase)
|
||||
|
||||
---
|
||||
|
||||
@@ -304,5 +304,6 @@ Thanks to all contributors and the open-source community!
|
||||
[Drizzle-url]: https://orm.drizzle.team/
|
||||
|
||||
[ShadcnUI-url]: https://ui.shadcn.com/
|
||||
|
||||
[Docker-url]: https://www.docker.com/
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import {CardContent, CardHeader} from "@/components/ui/card";
|
||||
|
||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
||||
import {ForgotPasswordForm} from "@/components/wrappers/auth/login/forgot-password-form/forgot-password-form";
|
||||
import {CardAuth} from "@/features/layout/card-auth";
|
||||
|
||||
export default async function RoutePage(props: { searchParams: Promise<{ callbackUrl: string | undefined }> }) {
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<CardAuth className="w-full">
|
||||
<CardHeader>
|
||||
<div className="grid gap-2 text-center mb-2">
|
||||
<h1 className="text-3xl font-bold">Reset password</h1>
|
||||
<p className="text-balance text-muted-foreground">Enter your email address and we'll send you a
|
||||
link to reset your password.</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ForgotPasswordForm/>
|
||||
</CardContent>
|
||||
</CardAuth>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {CardContent, CardHeader} from "@/components/ui/card";
|
||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
||||
import {GuardForm} from "@/components/wrappers/auth/guard/guard-form";
|
||||
import {cookies} from "next/headers";
|
||||
import {redirect} from "next/navigation";
|
||||
import {CardAuth} from "@/features/layout/card-auth";
|
||||
|
||||
export default async function GuardPage() {
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get("better-auth.two_factor")?.value;
|
||||
|
||||
if (!token) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<CardAuth className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<div className="grid gap-2 text-center mb-2">
|
||||
<h1 className="text-3xl font-bold">Two-factor verification</h1>
|
||||
<p className="text-balance text-muted-foreground">Please enter the verification code generated
|
||||
by your authentication app.</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<GuardForm/>
|
||||
</CardContent>
|
||||
</CardAuth>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,52 @@
|
||||
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";
|
||||
import {SUPPORTED_PROVIDERS} from "../../../portabase.config";
|
||||
import {SocialAuthButtons} from "@/components/wrappers/auth/social-buttons";
|
||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
||||
import {CardContent, CardHeader} from "@/components/ui/card";
|
||||
import Link from "next/link";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {CardAuth} from "@/features/layout/card-auth";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Login",
|
||||
};
|
||||
|
||||
export default async function SignInPage() {
|
||||
const authGoogleEnabled = env.AUTH_GOOGLE_METHOD;
|
||||
|
||||
return (
|
||||
<div className="mx-auto grid w-full gap-6">
|
||||
<LoginForm authGoogleEnabled={authGoogleEnabled}/>
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<CardAuth className="w-full">
|
||||
<CardHeader>
|
||||
<div className="grid gap-2 text-center mb-2">
|
||||
<h1 className="text-3xl font-bold">Login</h1>
|
||||
<p className="text-balance text-muted-foreground">Fill your login informations</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<LoginForm/>
|
||||
|
||||
{SUPPORTED_PROVIDERS.filter((p) => !p.isManual && p.isActive).length > 0 && (
|
||||
<>
|
||||
<div className="relative my-4 flex items-center justify-center overflow-hidden">
|
||||
<Separator/>
|
||||
<div className="px-2 text-center text-sm">OR</div>
|
||||
<Separator/>
|
||||
</div>
|
||||
<SocialAuthButtons providers={SUPPORTED_PROVIDERS}/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-4 text-center text-sm">
|
||||
Don't have an account ?{" "}
|
||||
<Link href="/register" className="underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</CardAuth>
|
||||
</TooltipProvider>
|
||||
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { ResetPasswordForm } from "@/components/wrappers/auth/login/reset-password-form/reset-password-form";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth/auth";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@radix-ui/react-avatar";
|
||||
|
||||
export default async function RoutePage(props: { searchParams: Promise<{ token: string | undefined }> }) {
|
||||
|
||||
const { token } = await props.searchParams;
|
||||
|
||||
if (!token) {
|
||||
return redirect(`/login?error=invalid_or_expired_token`);
|
||||
}
|
||||
|
||||
const verification = await (await auth.$context).internalAdapter.findVerificationValue(`reset-password:${token}`);
|
||||
|
||||
if (!verification || verification.expiresAt < new Date()) {
|
||||
return redirect(`/login?error=invalid_or_expired_token`);
|
||||
}
|
||||
|
||||
const user = await (await auth.$context).internalAdapter.findUserById(verification.value);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Card className="w-full max-w-md shadow-lg">
|
||||
<CardHeader className="space-y-4">
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Set a new password</h1>
|
||||
<p className="text-sm text-muted-foreground text-balance">Please enter your new password below.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center space-y-2 text-center">
|
||||
<Avatar className="relative flex h-16 w-16 shrink-0 overflow-hidden rounded-full border">
|
||||
<AvatarImage src={user!.image ?? ""} alt={user!.name} className="aspect-square h-full w-full object-cover" />
|
||||
<AvatarFallback className="flex h-full w-full items-center justify-center rounded-full bg-muted text-2xl font-medium text-muted-foreground">
|
||||
{user!.name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="font-semibold text-lg">{user!.name}</div>
|
||||
<div className="text-sm text-muted-foreground">{user!.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<ResetPasswordForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -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,41 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {db} from "@/db";
|
||||
import {desc, isNull} from "drizzle-orm";
|
||||
import {AdminUserList} from "@/components/wrappers/dashboard/admin/users/admin-user-list";
|
||||
import {AdminUserAddModal} from "@/components/wrappers/dashboard/admin/users/admin-user-add-modal";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
const users = await db.query.user.findMany({
|
||||
where: (fields) => isNull(fields.deletedAt),
|
||||
with: {
|
||||
accounts: true
|
||||
},
|
||||
orderBy: (fields) => desc(fields.createdAt),
|
||||
|
||||
});
|
||||
const organizations = await db.query.organization.findMany({
|
||||
with: {
|
||||
members: true,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader className="flex flex-col">
|
||||
<div className="flex justify-between">
|
||||
<PageTitle className="mb-3">Active users</PageTitle>
|
||||
<PageActions>
|
||||
<AdminUserAddModal organizations={organizations}/>
|
||||
</PageActions>
|
||||
</div>
|
||||
</PageHeader>
|
||||
<PageContent className="flex flex-col gap-5">
|
||||
<AdminUserList users={users}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,21 +2,16 @@ import Link from "next/link";
|
||||
import {GearIcon} from "@radix-ui/react-icons";
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageActions, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
||||
import {formatDateLastContact} from "@/utils/date-formatting";
|
||||
import {buttonVariants} from "@/components/ui/button";
|
||||
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
|
||||
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
|
||||
import {DatabaseCard} from "@/components/wrappers/dashboard/projects/project-card/project-database-card";
|
||||
import {AgentCardKey} from "@/components/wrappers/dashboard/agent/agent-card-key/agent-card-key";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {notFound} from "next/navigation";
|
||||
import {ButtonDeleteAgent} from "@/components/wrappers/dashboard/agent/button-delete-agent/button-delete-agent";
|
||||
import {capitalizeFirstLetter} from "@/utils/text";
|
||||
import {Server} from "lucide-react";
|
||||
import {generateEdgeKey} from "@/utils/edge_key";
|
||||
import {getServerUrl} from "@/utils/get-server-url";
|
||||
import {AgentContentPage} from "@/components/wrappers/dashboard/agent/agent-content";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{ agentId: string }>) {
|
||||
|
||||
@@ -49,44 +44,15 @@ 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">
|
||||
|
||||
<Card className="w-full sm:w-auto flex-1">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Databases</CardTitle>
|
||||
<Server className="h-4 w-4 text-muted-foreground"/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{agent.databases.length}</div>
|
||||
<p className="text-xs text-muted-foreground">Databases linked to this agent</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="w-full sm:w-auto flex-1">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Last contact</CardTitle>
|
||||
<Server className="h-4 w-4 text-muted-foreground"/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{formatDateLastContact(agent.lastContact)}</div>
|
||||
<p className="text-xs text-muted-foreground">Last contact with agent</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
<Card className="w-full sm:w-auto flex-1 mb-4">
|
||||
<CardHeader className="font-bold text-xl">
|
||||
Edge Key
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AgentCardKey
|
||||
edgeKey={edgeKey}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<CardsWithPagination cardsPerPage={2} data={agent.databases} cardItem={DatabaseCard}/>
|
||||
{agent.description && (
|
||||
<PageDescription className="mt-5 sm:mt-0">{agent.description}</PageDescription>
|
||||
)}
|
||||
<PageContent className="flex flex-col w-full h-full justify-between gap-6">
|
||||
<AgentContentPage
|
||||
agent={agent}
|
||||
edgeKey={edgeKey}
|
||||
/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
)
|
||||
|
||||
@@ -18,9 +18,14 @@ export const metadata: Metadata = {
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
const agents = await db.query.agent.findMany({
|
||||
where: not(eq(drizzleDb.schemas.agent.isArchived, true))
|
||||
where: not(eq(drizzleDb.schemas.agent.isArchived, true)),
|
||||
with: {
|
||||
databases: true
|
||||
}
|
||||
});
|
||||
|
||||
console.log(agents);
|
||||
|
||||
|
||||
if (!agents) {
|
||||
notFound();
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {Metadata} from "next";
|
||||
import {
|
||||
NotificationChannelsSection
|
||||
} from "@/components/wrappers/dashboard/admin/notifications/channels/notification-channels-section";
|
||||
import {db} from "@/db";
|
||||
import {notificationChannel, NotificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
||||
import {NotifierAddEditModal} from "@/components/wrappers/dashboard/common/notifier/notifier-add-edit-modal";
|
||||
import {desc, isNull} from "drizzle-orm";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Notification Channels",
|
||||
};
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
const notificationChannels = await db.query.notificationChannel.findMany({
|
||||
with: {
|
||||
organizations: true
|
||||
},
|
||||
orderBy: desc(notificationChannel.createdAt)
|
||||
}) as NotificationChannelWith[]
|
||||
|
||||
const organizations = await db.query.organization.findMany({
|
||||
where: (fields) => isNull(fields.deletedAt),
|
||||
with: {
|
||||
members: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle>Notification channels</PageTitle>
|
||||
<PageActions>
|
||||
<NotifierAddEditModal adminView={false}/>
|
||||
</PageActions>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<NotificationChannelsSection organizations={organizations} notificationChannels={notificationChannels}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {Metadata} from "next";
|
||||
import {NotificationLogsList} from "@/components/wrappers/dashboard/admin/notifications/logs/notification-logs-list";
|
||||
import {notFound} from "next/navigation";
|
||||
import {getNotificationHistory} from "@/db/services/notification-log";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Activity logs",
|
||||
};
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
const notificationLogs = await getNotificationHistory()
|
||||
|
||||
if (!notificationLogs) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle>Activity logs</PageTitle>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<NotificationLogsList notificationLogs={notificationLogs} />
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {notFound} from "next/navigation";
|
||||
import {RestoreForm} from "@/components/wrappers/dashboard/database/restore-form";
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
|
||||
import {db} from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
|
||||
|
||||
export default async function RoutePage(
|
||||
props: PageParams<{
|
||||
databaseId: string;
|
||||
}>
|
||||
) {
|
||||
const {databaseId} = await props.params;
|
||||
|
||||
const user = await currentUser();
|
||||
if (!user) notFound();
|
||||
|
||||
const [dbToRestore] = await db.select().from(drizzleDb.schemas.database).where(eq(drizzleDb.schemas.database.id, databaseId));
|
||||
|
||||
if (!dbToRestore) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const dbsOfSameType = await db.select().from(drizzleDb.schemas.database).where(eq(drizzleDb.schemas.database.dbms!, dbToRestore.dbms!));
|
||||
const successfulBackups = await db.select().from(drizzleDb.schemas.backup).where(eq(drizzleDb.schemas.backup.status, "success"));
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle>Restore {dbToRestore.name}</PageTitle>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<RestoreForm databaseToRestore={dbToRestore} databases={dbsOfSameType} backups={successfulBackups}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {notFound, redirect} from "next/navigation";
|
||||
import {Page, PageActions, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
||||
import {Page, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
||||
import {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
|
||||
import {DatabaseTabs} from "@/components/wrappers/dashboard/projects/database/database-tabs";
|
||||
import {DatabaseKpi} from "@/components/wrappers/dashboard/projects/database/database-kpi";
|
||||
import {EditButton} from "@/components/wrappers/dashboard/database/edit-button/edit-button";
|
||||
import {CronButton} from "@/components/wrappers/dashboard/database/cron-button/cron-button";
|
||||
|
||||
import {db} from "@/db";
|
||||
import {eq, and, inArray} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
@@ -14,6 +12,9 @@ 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";
|
||||
import {ImportModal} from "@/components/wrappers/dashboard/database/import/import-modal";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{
|
||||
projectId: string;
|
||||
@@ -37,7 +38,8 @@ export default async function RoutePage(props: PageParams<{
|
||||
where: and(inArray(drizzleDb.schemas.backup.id, databasesProject.ids ?? []), eq(drizzleDb.schemas.database.id, databaseId), eq(drizzleDb.schemas.database.projectId, projectId)),
|
||||
with: {
|
||||
project: true,
|
||||
retentionPolicy: true
|
||||
retentionPolicy: true,
|
||||
alertPolicies: true
|
||||
}
|
||||
});
|
||||
|
||||
@@ -82,25 +84,31 @@ export default async function RoutePage(props: PageParams<{
|
||||
notFound();
|
||||
}
|
||||
|
||||
const organizationChannels = await getOrganizationChannels(organization.id);
|
||||
const activeOrganizationChannels = organizationChannels.filter(channel => channel.enabled);
|
||||
|
||||
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
|
||||
|
||||
const isMember = activeMember?.role === "member";
|
||||
|
||||
|
||||
return (
|
||||
<Page>
|
||||
|
||||
<div className="justify-between gap-2 sm:flex">
|
||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full">
|
||||
<div className=" w-full md:w-fit">
|
||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||
<div className="min-w-full md:min-w-fit ">
|
||||
{capitalizeFirstLetter(dbItem.name)}
|
||||
</div>
|
||||
{!isMember && (
|
||||
<div className="flex items-center gap-2 md:justify-between w-full">
|
||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Do not delete*/}
|
||||
{/*<EditButton/>*/}
|
||||
<RetentionPolicySheet database={dbItem}/>
|
||||
<CronButton database={dbItem}/>
|
||||
<AlertPolicyModal database={dbItem} notificationChannels={activeOrganizationChannels}
|
||||
organizationId={organization.id}/>
|
||||
<ImportModal database={dbItem}/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<BackupButton disable={isAlreadyBackup} databaseId={databaseId}/>
|
||||
@@ -108,15 +116,18 @@ export default async function RoutePage(props: PageParams<{
|
||||
</div>
|
||||
)}
|
||||
</PageTitle>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{dbItem.description && (
|
||||
<PageDescription className="mt-5 sm:mt-0">{dbItem.description}</PageDescription>
|
||||
)}
|
||||
<PageContent className="flex flex-col w-full h-full">
|
||||
<DatabaseKpi successRate={successRate} database={dbItem} availableBackups={availableBackups}
|
||||
<DatabaseKpi successRate={successRate} database={dbItem} availableBackups={availableBackups}
|
||||
totalBackups={totalBackups}/>
|
||||
<DatabaseTabs activeMember={activeMember} settings={settings} database={dbItem} isAlreadyRestore={isAlreadyRestore}
|
||||
<DatabaseTabs activeMember={activeMember} settings={settings} database={dbItem}
|
||||
isAlreadyRestore={isAlreadyRestore}
|
||||
backups={backups}
|
||||
restorations={restorations}/>
|
||||
</PageContent>
|
||||
|
||||
@@ -7,10 +7,10 @@ import {
|
||||
DeleteOrganizationButton
|
||||
} from "@/components/wrappers/dashboard/organization/delete-organization/delete-organization-button";
|
||||
import {EditButtonSettings} from "@/components/wrappers/dashboard/settings/edit-button-settings/edit-button-settings";
|
||||
import {
|
||||
SettingsOrganizationMembersTable
|
||||
} from "@/components/wrappers/dashboard/settings/settings-organization-members-table";
|
||||
import {Metadata} from "next";
|
||||
import {OrganizationTabs} from "@/components/wrappers/dashboard/organization/tabs/organization-tabs";
|
||||
import {getOrganizationChannels} from "@/db/services/notification-channel";
|
||||
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Settings",
|
||||
@@ -21,30 +21,37 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
||||
const user = await currentUser();
|
||||
const activeMember = await getActiveMember()
|
||||
|
||||
if (!organization) {
|
||||
if (!organization || !activeMember) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const isMember = activeMember?.role === "member";
|
||||
const isOwner = activeMember?.role === "owner";
|
||||
const notificationChannels = await getOrganizationChannels(organization.id)
|
||||
|
||||
const permissions = computeOrganizationPermissions(activeMember);
|
||||
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle className="flex items-center">
|
||||
Organization settings
|
||||
{!isMember && organization.slug !== "default" && (
|
||||
{permissions.canManageSettings && organization.slug !== "default" && (
|
||||
<EditButtonSettings/>
|
||||
)}
|
||||
</PageTitle>
|
||||
|
||||
<PageActions>
|
||||
{isOwner && organization.slug !== "default" && (
|
||||
{permissions.canManageDangerZone && organization.slug !== "default" && (
|
||||
<DeleteOrganizationButton organizationSlug={organization.slug}/>
|
||||
)}
|
||||
</PageActions>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<SettingsOrganizationMembersTable organization={organization}/>
|
||||
<OrganizationTabs
|
||||
activeMember={activeMember}
|
||||
organization={organization}
|
||||
notificationChannels={notificationChannels}
|
||||
/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
)
|
||||
|
||||
@@ -78,9 +78,6 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
);
|
||||
|
||||
const Placeholder = ({text}: { text: string }) => (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">{text}</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
@@ -125,31 +122,12 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Evolution of the number of backups</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sortedBackupsEvolution.length > 0 ? (
|
||||
<EvolutionLineChart data={sortedBackupsEvolution}/>
|
||||
) : (
|
||||
<Placeholder text="No backup data available"/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<EvolutionLineChart
|
||||
data={sortedBackupsEvolution}
|
||||
/>
|
||||
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Success rate of backups</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{backupsRate.length > 0 ? (
|
||||
<PercentageLineChart data={backupsRate}/>
|
||||
) : (
|
||||
<Placeholder text="No backup rate data available"/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<PercentageLineChart
|
||||
data={backupsRate}/>
|
||||
</div>
|
||||
</PageContent>
|
||||
</Page>
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import React from "react";
|
||||
import React, {ReactNode} from "react";
|
||||
import {redirect} from "next/navigation";
|
||||
|
||||
import {SidebarInset, SidebarProvider} from "@/components/ui/sidebar";
|
||||
import {AppSidebar} from "@/components/wrappers/dashboard/common/sidebar/app-sidebar";
|
||||
import {Header} from "@/features/layout/Header";
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
import {ThemeMetaUpdater} from "@/features/browser/theme-meta-updater";
|
||||
|
||||
export default async function Layout({children}: { children: React.ReactNode }) {
|
||||
export default async function Layout({children}: { children: ReactNode }) {
|
||||
const user = await currentUser();
|
||||
if (!user) redirect("/login");
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarProvider>
|
||||
<div className="flex flex-col lg:flex-row w-full">
|
||||
<AppSidebar/>
|
||||
<SidebarInset>
|
||||
<Header/>
|
||||
<main className="h-full">{children}</main>
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</>
|
||||
<SidebarProvider>
|
||||
<div className="flex flex-col lg:flex-row w-full">
|
||||
<ThemeMetaUpdater/>
|
||||
<AppSidebar/>
|
||||
<SidebarInset>
|
||||
<Header/>
|
||||
<main className="h-full">{children}</main>
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageTitle} from "@/features/layout/page";
|
||||
import {notFound} from "next/navigation";
|
||||
import {UserForm} from "@/components/wrappers/dashboard/profile/user-form/user-form";
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
import {ButtonDeleteAccount} from "@/components/wrappers/dashboard/profile/button-delete-account/button-delete-account";
|
||||
import {AvatarWithUpload} from "@/components/wrappers/dashboard/profile/avatar/avatar-with-upload";
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
import {getAccounts, getSessions} from "@/lib/auth/auth";
|
||||
import {Metadata} from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Profile",
|
||||
};
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
const user = await currentUser();
|
||||
if (!user) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
if (user.role !== "user" && user.role !== "admin" && user.role !== "superadmin") {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const sessions = await getSessions();
|
||||
const accounts = await getAccounts();
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="justify-between gap-2 sm:flex">
|
||||
<PageTitle className="flex items-center">
|
||||
<AvatarWithUpload
|
||||
user={{
|
||||
...user,
|
||||
image: user.image ?? null,
|
||||
role: user.role ?? null,
|
||||
banned: user.banned ?? null,
|
||||
banReason: user.banReason ?? null,
|
||||
banExpires: user.banExpires ?? null,
|
||||
deletedAt: user.deletedAt ? new Date(user.deletedAt) : null,
|
||||
}}
|
||||
/>
|
||||
{user.name}
|
||||
<Badge className="ml-3 hidden lg:block">{user.role}</Badge>
|
||||
</PageTitle>
|
||||
</div>
|
||||
<PageContent>
|
||||
<UserForm
|
||||
userId={user.id}
|
||||
sessions={sessions} accounts={accounts}
|
||||
defaultValues={{
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role ?? undefined,
|
||||
}}
|
||||
/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -7,7 +7,7 @@ import {Database} from "@/db/schema/07_database";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {db as dbClient} from "@/db";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types";
|
||||
import {EDbmsSchema} from "@/db/schema/types";
|
||||
import {ServerActionResult} from "@/types/action-type";
|
||||
import {SafeActionResult} from "next-safe-action";
|
||||
import {ZodString} from "zod";
|
||||
@@ -64,10 +64,13 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
}
|
||||
} else {
|
||||
|
||||
|
||||
const [databaseUpdated] = await dbClient
|
||||
.update(drizzleDb.schemas.database)
|
||||
.set({lastContact: lastContact})
|
||||
.set(withUpdatedAt({
|
||||
name: db.name,
|
||||
agentId: agent.id,
|
||||
lastContact: lastContact
|
||||
}))
|
||||
.where(eq(drizzleDb.schemas.database.id, existingDatabase.id))
|
||||
.returning();
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {db} from "@/db";
|
||||
import {EDbmsSchema} from "@/db/schema/types";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {isUuidv4} from "@/utils/verify-uuid";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
|
||||
export type databaseAgent = {
|
||||
name: string,
|
||||
@@ -15,12 +16,13 @@ export type databaseAgent = {
|
||||
}
|
||||
|
||||
export type Body = {
|
||||
version: string,
|
||||
databases: databaseAgent[]
|
||||
}
|
||||
|
||||
// Function to test the get file url presigned local
|
||||
export async function GET(request: Request) {
|
||||
const url = await getFileUrlPresignedLocal({fileName:"d4a7fa35-2506-4d01-a612-a8ef2e2cc1c5.dump"})
|
||||
const url = await getFileUrlPresignedLocal({fileName: "d4a7fa35-2506-4d01-a612-a8ef2e2cc1c5.dump"})
|
||||
return Response.json({
|
||||
message: url
|
||||
})
|
||||
@@ -60,7 +62,10 @@ export async function POST(
|
||||
|
||||
await db
|
||||
.update(drizzleDb.schemas.agent)
|
||||
.set({lastContact: lastContact})
|
||||
.set(withUpdatedAt({
|
||||
version: body.version,
|
||||
lastContact: lastContact
|
||||
}))
|
||||
.where(eq(drizzleDb.schemas.agent.id, agentId));
|
||||
|
||||
eventEmitter.emit('modification', {update: true});
|
||||
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 3.9 KiB |
@@ -16,19 +16,22 @@ export const metadata: Metadata = {
|
||||
description: process.env.PROJECT_DESCRIPTION ?? undefined,
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<meta name="apple-mobile-web-app-title" content="Portabase"/>
|
||||
<meta name="apple-mobile-web-app-title" content={title}/>
|
||||
</head>
|
||||
<body className={cn(inter.className, "h-full")}>
|
||||
<ConsoleSilencer/>
|
||||
<Providers>{children}</Providers>
|
||||
<Providers>
|
||||
{children}
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { PropsWithChildren, Suspense } from "react";
|
||||
import { ThemeProvider } from "@/features/theme/theme-provider";
|
||||
import {PropsWithChildren, Suspense} from "react";
|
||||
import {ThemeProvider} from "@/features/theme/theme-provider";
|
||||
|
||||
import {Toaster} from "@/components/ui/sonner";
|
||||
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
|
||||
import {ThemeMetaUpdaterRoot} from "@/features/browser/theme-meta-updater-root";
|
||||
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
export type ProviderProps = PropsWithChildren<{}>;
|
||||
const queryClient = new QueryClient();
|
||||
@@ -12,9 +14,14 @@ const queryClient = new QueryClient();
|
||||
export const Providers = (props: ProviderProps) => {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
>
|
||||
<ThemeMetaUpdaterRoot/>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Toaster />
|
||||
<Toaster/>
|
||||
{props.children}
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -3,11 +3,11 @@ name: portabase-prod
|
||||
services:
|
||||
|
||||
app:
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: docker/dockerfile/Dockerfile
|
||||
# target: prod
|
||||
image: solucetechnologies/portabase:1.1.3-rc.3
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: docker/dockerfile/Dockerfile
|
||||
# target: prod
|
||||
image: solucetechnologies/portabase:1.1.4-rc.2
|
||||
ports:
|
||||
- '8887:80'
|
||||
environment:
|
||||
@@ -35,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:
|
||||
|
||||
@@ -1,27 +1,6 @@
|
||||
name: portabase-dev
|
||||
|
||||
services:
|
||||
|
||||
# app:
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: docker/dockerfile/Dockerfile
|
||||
# target: dev
|
||||
# ports:
|
||||
# - "8887:8887"
|
||||
# environment:
|
||||
# - TZ=Europe/Paris
|
||||
# - NODE_ENV=development
|
||||
# depends_on:
|
||||
# db:
|
||||
# condition: service_healthy
|
||||
# volumes:
|
||||
# - .:/app
|
||||
# - /app/node_modules
|
||||
# container_name: portabase-app
|
||||
|
||||
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
ports:
|
||||
|
||||
@@ -110,3 +110,4 @@ USER nextjs
|
||||
|
||||
ENTRYPOINT ["sh","/app/app-prod-entrypoint.sh"]
|
||||
|
||||
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
#!/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
|
||||
echo "[INFO] Application timezone set to $TZ (environment only)"
|
||||
export TZ="$TZ"
|
||||
else
|
||||
echo "[WARN] No TZ provided, using default container timezone"
|
||||
fi
|
||||
|
||||
|
||||
node server.js
|
||||
|
||||
exec "$@"
|
||||
exec "$@"
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { Button as ReactEmailButton } from "@react-email/components";
|
||||
import { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export const Button = (props: ComponentPropsWithoutRef<typeof ReactEmailButton>) => {
|
||||
return <ReactEmailButton className="block w-52 rounded bg-blue-600 py-3.5 text-center text-sm font-normal text-white no-underline " {...props} />;
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Body, Container, Head, Html, Img, Preview, Section, Tailwind } from "@react-email/components";
|
||||
import * as React from "react";
|
||||
import { PropsWithChildren } from "react";
|
||||
import { getServerUrl } from "@/utils/get-server-url";
|
||||
|
||||
const baseUrl = getServerUrl();
|
||||
|
||||
export const EmailLayout = ({ children, preview }: PropsWithChildren<{ preview?: string }>) => {
|
||||
return (
|
||||
<Tailwind>
|
||||
<Html>
|
||||
<Head />
|
||||
{preview ? <Preview>{preview}</Preview> : <Preview>Please check your mails</Preview>}
|
||||
<Body className="bg-gray-100 py-4" style={{ fontFamily: "Arial, sans-serif" }}>
|
||||
<Container className="bg-white border border-gray-200 p-12">
|
||||
<Img src={`${baseUrl}/logo-title-black.png`} width="200" height="auto" alt="Logo" />
|
||||
<Section>{children}</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
</Tailwind>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailLayout;
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Button } from "@react-email/button";
|
||||
import { Html } from "@react-email/html";
|
||||
import * as React from "react";
|
||||
|
||||
export default function HelloEmail() {
|
||||
return (
|
||||
<Html>
|
||||
<Button href="https://example.com" style={{ background: "#000", color: "#fff" }}>
|
||||
Hello,
|
||||
</Button>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Text } from "@react-email/components";
|
||||
import * as React from "react";
|
||||
import EmailLayout from "./EmailLayout";
|
||||
import { Button } from "@react-email/button";
|
||||
|
||||
export const TestEmailSettings = () => {
|
||||
return (
|
||||
<EmailLayout preview="Email Setup">
|
||||
<Text className="text-base font-light leading-8 text-green-800 ">Hi, your email settings are setup !</Text>
|
||||
<Text className="text-base font-light leading-8 text-green-800 ">Best regard,</Text>{" "}
|
||||
<Text className="text-base font-light leading-8 text-green-800 ">Portabase</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default TestEmailSettings;
|
||||
@@ -1,6 +0,0 @@
|
||||
import { Text as ReactEmailText } from "@react-email/components";
|
||||
import { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export const Text = (props: ComponentPropsWithoutRef<typeof ReactEmailText>) => {
|
||||
return <ReactEmailText className="text-base font-light leading-8 text-green-800 " {...props} />;
|
||||
};
|
||||
@@ -41,6 +41,11 @@ const nextConfig: NextConfig = {
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: "50mb",
|
||||
},
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "portabase",
|
||||
"version": "1.1.3",
|
||||
"version": "1.1.6",
|
||||
"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.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -66,7 +66,7 @@
|
||||
"lucide-react": "^0.553.0",
|
||||
"minio": "^8.0.5",
|
||||
"motion": "^12.23.24",
|
||||
"next": "16.0.0",
|
||||
"next": "16.0.10",
|
||||
"next-safe-action": "^7.10.8",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-cron": "^4.2.1",
|
||||
@@ -80,6 +80,7 @@
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-email": "^4.0.13",
|
||||
"react-hook-form": "^7.56.3",
|
||||
"react-qr-code": "^2.0.18",
|
||||
"react-resizable-panels": "^3.0.2",
|
||||
"react-twc": "^1.4.2",
|
||||
"react-use-measure": "^2.1.7",
|
||||
@@ -95,6 +96,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/react": "^6.0.0",
|
||||
"@react-email/preview-server": "4.3.2",
|
||||
"@tailwindcss/postcss": "^4.1.7",
|
||||
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
||||
"@types/node": "^22.15.18",
|
||||
@@ -104,6 +106,7 @@
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@zenstackhq/openapi": "^2.14.2",
|
||||
"@zenstackhq/tanstack-query": "^2.14.2",
|
||||
"baseline-browser-mapping": "^2.8.32",
|
||||
"drizzle-kit": "^0.31.1",
|
||||
"eslint": "^9.39.0",
|
||||
"eslint-config-next": "^16.0.1",
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
|
||||
|
||||
export interface AuthProviderConfig {
|
||||
id: "google" | "github" | "credential";
|
||||
isActive: boolean;
|
||||
icon: string;
|
||||
isManual?: boolean;
|
||||
credentials?: {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export const PORTABASE_DEFAULT_SETTINGS = {
|
||||
SECURITY: {
|
||||
CSP: {
|
||||
@@ -62,3 +75,36 @@ export const PORTABASE_DEFAULT_SETTINGS = {
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
export const SUPPORTED_PROVIDERS: AuthProviderConfig[] = [
|
||||
{
|
||||
id: "google",
|
||||
icon: "hugeicons:chrome",
|
||||
isActive: Boolean(process.env.AUTH_GOOGLE_METHOD),
|
||||
// isManual: true,
|
||||
credentials: {
|
||||
clientId: process.env.AUTH_GOOGLE_ID || "",
|
||||
clientSecret: process.env.AUTH_GOOGLE_SECRET || "",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "github",
|
||||
icon: "iconoir:github",
|
||||
isActive: Boolean(process.env.AUTH_GITHUB_METHOD),
|
||||
credentials: {
|
||||
clientId: process.env.AUTH_GITHUB_ID || "",
|
||||
clientSecret: process.env.AUTH_GITHUB_SECRET || "",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "credential",
|
||||
isActive: true,
|
||||
icon: "proicons:key",
|
||||
isManual: true,
|
||||
credentials: {
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
|
Before Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,38 @@
|
||||
import * as React from "react";
|
||||
import EmailLayout from "../email-layout";
|
||||
import {Heading, Text, Section, Button} from "@react-email/components";
|
||||
import {getServerUrl} from "@/utils/get-server-url";
|
||||
|
||||
interface EmailCreateUserProps {
|
||||
firstname?: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export const EmailForgotPassword = ({firstname, token}: EmailCreateUserProps) => {
|
||||
const baseUrl = getServerUrl();
|
||||
|
||||
return (
|
||||
<EmailLayout preview="Portabase - Forgot Password">
|
||||
<Heading className="mx-0 my-[30px] p-0 text-center font-normal text-[24px] text-black">
|
||||
Hello <strong>{firstname}</strong>,
|
||||
</Heading>
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
We received a request to reset the password for your account associated with this email.
|
||||
</Text>
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
If you did not request this password reset, please ignore this email. Your current password
|
||||
will remain unchanged. If this seems suspicious, please contact your administrator.
|
||||
</Text>
|
||||
<Section className="mt-[32px] mb-[32px] text-center">
|
||||
<Button
|
||||
className="rounded bg-[#000000] px-5 py-3 text-center font-semibold text-[12px] text-white no-underline"
|
||||
href={`${baseUrl}/reset-password?token=${token}`}
|
||||
>
|
||||
Reset my password
|
||||
</Button>
|
||||
</Section>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailForgotPassword;
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as React from "react";
|
||||
import EmailLayout from "../email-layout";
|
||||
import {Heading, Text} from "@react-email/components";
|
||||
|
||||
interface EmailCreateUserProps {
|
||||
firstname?: string;
|
||||
os: string;
|
||||
browser: string;
|
||||
ipAddress?: string;
|
||||
}
|
||||
|
||||
export const EmailNewLogin = ({firstname, ipAddress, os, browser}: EmailCreateUserProps) => {
|
||||
return (
|
||||
<EmailLayout preview="Portabase - New Login">
|
||||
<Heading className="mx-0 my-[30px] p-0 text-center font-normal text-[24px] text-black">
|
||||
Hello <strong>{firstname}</strong>,
|
||||
</Heading>
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
We detected a new login to your account.
|
||||
</Text>
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
<strong>Login details:</strong>
|
||||
<br/>
|
||||
Device: {os} - {browser}
|
||||
<br/>
|
||||
IP Address: {ipAddress}
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailNewLogin;
|
||||
@@ -0,0 +1,51 @@
|
||||
import {Heading, Text, Section, Button} from "@react-email/components";
|
||||
import {getServerUrl} from "@/utils/get-server-url";
|
||||
import EmailLayout from "@/components/emails/email-layout";
|
||||
|
||||
interface EmailCreateUserProps {
|
||||
firstname?: string;
|
||||
oldEmail?: string;
|
||||
newEmail?: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const EmailVerification = ({firstname, oldEmail, newEmail, url: urlVerification}: EmailCreateUserProps) => {
|
||||
const serverUrl = new URL(getServerUrl());
|
||||
const url = new URL(urlVerification);
|
||||
url.hostname = serverUrl.hostname;
|
||||
url.port = serverUrl.port == "80" ? "" : serverUrl.port;
|
||||
const newUrl = url.toString();
|
||||
|
||||
return (
|
||||
<EmailLayout preview="Portabase email verification">
|
||||
<Heading className="mx-0 my-[30px] p-0 text-center font-normal text-[24px] text-black">
|
||||
Hello <strong>{firstname}</strong>,
|
||||
</Heading>
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
We received a request to change the email address associated with your account.
|
||||
</Text>
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
If you did not request this change, please ignore this email. Your current email address will remain
|
||||
unchanged. If this seems suspicious, contact your administrator.
|
||||
</Text>
|
||||
|
||||
{oldEmail && newEmail ? (
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
<strong>Old email address:</strong> {oldEmail}
|
||||
<br/>
|
||||
<strong>New email address:</strong> {newEmail}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Section className="mt-[32px] mb-[32px] text-center">
|
||||
<Button
|
||||
className="rounded bg-[#000000] px-5 py-3 text-center font-semibold text-[12px] text-white no-underline"
|
||||
href={newUrl}>
|
||||
Confirm email address
|
||||
</Button>
|
||||
</Section>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailVerification;
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as React from "react";
|
||||
import EmailLayout from "./email-layout";
|
||||
import {Heading, Text, Section, Button} from "@react-email/components";
|
||||
import {getServerUrl} from "@/utils/get-server-url";
|
||||
import {env} from "@/env.mjs";
|
||||
|
||||
interface EmailCreateUserProps {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export const EmailCreateUser = ({email, password}: EmailCreateUserProps) => {
|
||||
const baseUrl = getServerUrl();
|
||||
|
||||
|
||||
return (
|
||||
<EmailLayout preview="Portabase Dashboard">
|
||||
<Heading className="mx-0 my-[30px] p-0 text-center font-normal text-[24px] text-black">
|
||||
Your account on {env.PROJECT_NAME} has just been created!
|
||||
</Heading>
|
||||
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
<strong>Email: </strong>{email}
|
||||
</Text>
|
||||
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
<strong>Default password: </strong>{password}
|
||||
</Text>
|
||||
|
||||
<Section className="mt-[32px] mb-[32px] text-center">
|
||||
<Button
|
||||
className="rounded bg-[#000000] px-5 py-3 text-center font-semibold text-[12px] text-white no-underline"
|
||||
href={baseUrl}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
</Section>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailCreateUser;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Body, Container, Head, Html, Img, Preview, Section, Tailwind } from "@react-email/components";
|
||||
import * as React from "react";
|
||||
import { PropsWithChildren } from "react";
|
||||
import { getServerUrl } from "@/utils/get-server-url";
|
||||
|
||||
const baseUrl = getServerUrl();
|
||||
|
||||
export const EmailLayout = ({ children, preview }: PropsWithChildren<{ preview?: string }>) => {
|
||||
return (
|
||||
<Tailwind>
|
||||
<Html>
|
||||
<Head />
|
||||
<Body className="mx-auto my-auto bg-white px-2 font-sans">
|
||||
{preview ? <Preview>{preview}</Preview> : <Preview>Please check your mails</Preview>}
|
||||
<Container className="mx-auto my-[40px] max-w-[465px] rounded border border-[#eaeaea] border-solid p-[20px]">
|
||||
<Section className="mt-[32px]">
|
||||
<Img
|
||||
src={`${baseUrl}/images/logo.png`}
|
||||
width="50"
|
||||
height="auto"
|
||||
alt="Logo"
|
||||
className="mx-auto my-0"
|
||||
/>
|
||||
</Section>
|
||||
{children}
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
</Tailwind>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailLayout;
|
||||
@@ -0,0 +1,97 @@
|
||||
// import * as React from "react";
|
||||
// import EmailLayout from "./email-layout";
|
||||
// import {Text, Section, Button} from "@react-email/components";
|
||||
// import type {EventPayload} from "@/features/notifications/types";
|
||||
//
|
||||
// export interface EmailNotificationProps {
|
||||
// payload: EventPayload
|
||||
// }
|
||||
//
|
||||
// export const EmailNotification = ({payload}: EmailNotificationProps) => {
|
||||
// return (
|
||||
// <EmailLayout preview={payload.title}>
|
||||
// <Text className="text-base text-green-800 font-bold ">${payload.title}</Text>
|
||||
// <Text className="text-base text-green-800 font-bold "><strong>Level:</strong> ${payload.level}</Text>
|
||||
//
|
||||
//
|
||||
// <Text className="text-base font-light text-green-800 ">
|
||||
// ${payload.message.replace(/\n/g, '<br>')}
|
||||
// </Text>{" "}
|
||||
//
|
||||
//
|
||||
// <Section className="mt-[32px] mb-[32px] text-center">
|
||||
//
|
||||
// </Section>
|
||||
//
|
||||
// {payload.data && (
|
||||
// <Section className="mt-[16px] text-sm text-gray-700">
|
||||
// <pre>{JSON.stringify(payload.data, null, 2)}</pre>
|
||||
// </Section>
|
||||
// )}
|
||||
//
|
||||
// <Text className="text-base font-light text-green-800 ">Regards,<br/>Portabase</Text>
|
||||
// </EmailLayout>
|
||||
// );
|
||||
// };
|
||||
//
|
||||
// export default EmailNotification;
|
||||
|
||||
|
||||
// const html = `
|
||||
// <h2>${payload.title}</h2>
|
||||
// <p><strong>Level:</strong> ${payload.level}</p>
|
||||
// <p>${payload.message.replace(/\n/g, '<br>')}</p>
|
||||
// ${payload.data ? `<pre>${JSON.stringify(payload.data, null, 2)}</pre>` : ''}
|
||||
// `;
|
||||
//
|
||||
|
||||
import * as React from "react";
|
||||
import EmailLayout from "./email-layout";
|
||||
import {Text, Section, Button} from "@react-email/components";
|
||||
import type {EventPayload} from "@/features/notifications/types";
|
||||
|
||||
export interface EmailNotificationProps {
|
||||
payload: EventPayload;
|
||||
}
|
||||
|
||||
export const EmailNotification = ({payload}: EmailNotificationProps) => {
|
||||
return (
|
||||
<EmailLayout preview={payload.message}>
|
||||
<Section>
|
||||
<Text className="text-xl font-bold ">
|
||||
{payload.title}
|
||||
</Text>
|
||||
<Text className=" font-semibold ">
|
||||
Level: {payload.level}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{payload.message && (
|
||||
<Section className="mb-2">
|
||||
<Text
|
||||
className="text-base font-light"
|
||||
dangerouslySetInnerHTML={{__html: payload.message.replace(/\n/g, "<br>")}}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{payload.data && (
|
||||
<Section className="mb-6 p-4 bg-gray-100 rounded text-sm text-gray-700">
|
||||
<Text>
|
||||
<pre>{JSON.stringify(payload.data, null, 2)}</pre>
|
||||
</Text>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section className="mt-8">
|
||||
<Text className="text-base font-light ">
|
||||
Regards,
|
||||
<br/>
|
||||
Portabase
|
||||
</Text>
|
||||
</Section>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailNotification;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Text } from "@react-email/components";
|
||||
import * as React from "react";
|
||||
import EmailLayout from "./email-layout";
|
||||
|
||||
export const EmailSettingsTest = () => {
|
||||
return (
|
||||
<EmailLayout preview="Email Setup">
|
||||
<Text className="text-base font-light leading-8 ">Hi, your email settings are setup !</Text>
|
||||
<Text className="text-base font-light leading-8 ">Best regard,</Text>{" "}
|
||||
<Text className="text-base font-light leading-8 ">Portabase</Text>
|
||||
</EmailLayout>
|
||||
)
|
||||
};
|
||||
|
||||
export default EmailSettingsTest;
|
||||
@@ -0,0 +1,6 @@
|
||||
import {Text as ReactEmailText} from "@react-email/components";
|
||||
import {ComponentPropsWithoutRef} from "react";
|
||||
|
||||
export const EmailText = (props: ComponentPropsWithoutRef<typeof ReactEmailText>) => {
|
||||
return <ReactEmailText className="text-base font-light leading-8 text-green-800 " {...props} />;
|
||||
};
|
||||
@@ -7,117 +7,118 @@ import { XIcon } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
"max-h-[90vh] overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,850 @@
|
||||
"use client"
|
||||
import {cn} from "@/lib/utils";
|
||||
import {
|
||||
createContext,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useContext,
|
||||
useId,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Accept,
|
||||
FileRejection,
|
||||
useDropzone as rootUseDropzone,
|
||||
} from "react-dropzone";
|
||||
import {Button, ButtonVariantsProps} from "@/components/ui/button";
|
||||
|
||||
type DropzoneResult<TUploadRes, TUploadError> =
|
||||
| {
|
||||
status: "pending";
|
||||
}
|
||||
| {
|
||||
status: "error";
|
||||
error: TUploadError;
|
||||
}
|
||||
| {
|
||||
status: "success";
|
||||
result: TUploadRes;
|
||||
};
|
||||
|
||||
export type FileStatus<TUploadRes, TUploadError> = {
|
||||
id: string;
|
||||
fileName: string;
|
||||
file: File;
|
||||
tries: number;
|
||||
} & (
|
||||
| {
|
||||
status: "pending";
|
||||
result?: undefined;
|
||||
error?: undefined;
|
||||
}
|
||||
| {
|
||||
status: "error";
|
||||
error: TUploadError;
|
||||
result?: undefined;
|
||||
}
|
||||
| {
|
||||
status: "success";
|
||||
result: TUploadRes;
|
||||
error?: undefined;
|
||||
}
|
||||
);
|
||||
|
||||
const fileStatusReducer = <TUploadRes, TUploadError>(
|
||||
state: FileStatus<TUploadRes, TUploadError>[],
|
||||
action:
|
||||
| {
|
||||
type: "add";
|
||||
id: string;
|
||||
fileName: string;
|
||||
file: File;
|
||||
}
|
||||
| {
|
||||
type: "remove";
|
||||
id: string;
|
||||
}
|
||||
| ({
|
||||
type: "update-status";
|
||||
id: string;
|
||||
} & DropzoneResult<TUploadRes, TUploadError>),
|
||||
): FileStatus<TUploadRes, TUploadError>[] => {
|
||||
switch (action.type) {
|
||||
case "add":
|
||||
return [
|
||||
...state,
|
||||
{
|
||||
id: action.id,
|
||||
fileName: action.fileName,
|
||||
file: action.file,
|
||||
status: "pending",
|
||||
tries: 1,
|
||||
},
|
||||
];
|
||||
case "remove":
|
||||
return state.filter((fileStatus) => fileStatus.id !== action.id);
|
||||
case "update-status":
|
||||
return state.map((fileStatus) => {
|
||||
if (fileStatus.id === action.id) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const {id, type, ...rest} = action;
|
||||
return {
|
||||
...fileStatus,
|
||||
...rest,
|
||||
tries:
|
||||
action.status === "pending"
|
||||
? fileStatus.tries + 1
|
||||
: fileStatus.tries,
|
||||
} as FileStatus<TUploadRes, TUploadError>;
|
||||
}
|
||||
return fileStatus;
|
||||
});
|
||||
}
|
||||
};
|
||||
type DropZoneErrorCode = (typeof dropZoneErrorCodes)[number];
|
||||
const dropZoneErrorCodes = [
|
||||
"file-invalid-type",
|
||||
"file-too-large",
|
||||
"file-too-small",
|
||||
"too-many-files",
|
||||
] as const;
|
||||
|
||||
const getDropZoneErrorCodes = (fileRejections: FileRejection[]) => {
|
||||
const errors = fileRejections.map((rejection) => {
|
||||
return rejection.errors
|
||||
.filter((error) =>
|
||||
dropZoneErrorCodes.includes(error.code as DropZoneErrorCode),
|
||||
)
|
||||
.map((error) => error.code) as DropZoneErrorCode[];
|
||||
});
|
||||
return Array.from(new Set(errors.flat()));
|
||||
};
|
||||
|
||||
const getRootError = (
|
||||
errorCodes: DropZoneErrorCode[],
|
||||
limits: {
|
||||
accept?: Accept;
|
||||
maxSize?: number;
|
||||
minSize?: number;
|
||||
maxFiles?: number;
|
||||
},
|
||||
) => {
|
||||
const errors = errorCodes.map((error) => {
|
||||
switch (error) {
|
||||
case "file-invalid-type":
|
||||
const acceptedTypes = Object.values(limits.accept ?? {})
|
||||
.flat()
|
||||
.join(", ");
|
||||
return `only ${acceptedTypes} are allowed`;
|
||||
case "file-too-large":
|
||||
const maxMb = limits.maxSize
|
||||
? (limits.maxSize / (1024 * 1024)).toFixed(2)
|
||||
: "infinite?";
|
||||
return `max size is ${maxMb}MB`;
|
||||
case "file-too-small":
|
||||
const roundedMinSize = limits.minSize
|
||||
? (limits.minSize / (1024 * 1024)).toFixed(2)
|
||||
: "negative?";
|
||||
return `min size is ${roundedMinSize}MB`;
|
||||
case "too-many-files":
|
||||
return `max ${limits.maxFiles} files`;
|
||||
}
|
||||
});
|
||||
const joinedErrors = errors.join(", ");
|
||||
return joinedErrors.charAt(0).toUpperCase() + joinedErrors.slice(1);
|
||||
};
|
||||
|
||||
type UseDropzoneProps<TUploadRes, TUploadError> = {
|
||||
onDropFile: (
|
||||
file: File,
|
||||
) => Promise<
|
||||
Exclude<DropzoneResult<TUploadRes, TUploadError>, { status: "pending" }>
|
||||
>;
|
||||
onRemoveFile?: (id: string) => void | Promise<void>;
|
||||
onFileUploaded?: (result: TUploadRes) => void;
|
||||
onFileUploadError?: (error: TUploadError) => void;
|
||||
onAllUploaded?: () => void;
|
||||
onRootError?: (error: string | undefined) => void;
|
||||
maxRetryCount?: number;
|
||||
autoRetry?: boolean;
|
||||
validation?: {
|
||||
accept?: Accept;
|
||||
minSize?: number;
|
||||
maxSize?: number;
|
||||
maxFiles?: number;
|
||||
};
|
||||
shiftOnMaxFiles?: boolean;
|
||||
} & (TUploadError extends string
|
||||
? {
|
||||
shapeUploadError?: (error: TUploadError) => string | void;
|
||||
}
|
||||
: {
|
||||
shapeUploadError: (error: TUploadError) => string | void;
|
||||
});
|
||||
|
||||
interface UseDropzoneReturn<TUploadRes, TUploadError> {
|
||||
getRootProps: ReturnType<typeof rootUseDropzone>["getRootProps"];
|
||||
getInputProps: ReturnType<typeof rootUseDropzone>["getInputProps"];
|
||||
onRemoveFile: (id: string) => Promise<void>;
|
||||
onRetry: (id: string) => Promise<void>;
|
||||
canRetry: (id: string) => boolean;
|
||||
fileStatuses: FileStatus<TUploadRes, TUploadError>[];
|
||||
isInvalid: boolean;
|
||||
isDragActive: boolean;
|
||||
rootError: string | undefined;
|
||||
inputId: string;
|
||||
rootMessageId: string;
|
||||
rootDescriptionId: string;
|
||||
getFileMessageId: (id: string) => string;
|
||||
}
|
||||
|
||||
const useDropzone = <TUploadRes, TUploadError = string>(
|
||||
props: UseDropzoneProps<TUploadRes, TUploadError>,
|
||||
): UseDropzoneReturn<TUploadRes, TUploadError> => {
|
||||
const {
|
||||
onDropFile: pOnDropFile,
|
||||
onRemoveFile: pOnRemoveFile,
|
||||
shapeUploadError: pShapeUploadError,
|
||||
onFileUploaded: pOnFileUploaded,
|
||||
onFileUploadError: pOnFileUploadError,
|
||||
onAllUploaded: pOnAllUploaded,
|
||||
onRootError: pOnRootError,
|
||||
maxRetryCount,
|
||||
autoRetry,
|
||||
validation,
|
||||
shiftOnMaxFiles,
|
||||
} = props;
|
||||
|
||||
const inputId = useId();
|
||||
const rootMessageId = `${inputId}-root-message`;
|
||||
const rootDescriptionId = `${inputId}-description`;
|
||||
const [rootError, _setRootError] = useState<string | undefined>(undefined);
|
||||
|
||||
const setRootError = useCallback(
|
||||
(error: string | undefined) => {
|
||||
_setRootError(error);
|
||||
if (pOnRootError !== undefined) {
|
||||
pOnRootError(error);
|
||||
}
|
||||
},
|
||||
[pOnRootError, _setRootError],
|
||||
);
|
||||
|
||||
const [fileStatuses, dispatch] = useReducer(fileStatusReducer, []);
|
||||
|
||||
const isInvalid = useMemo(() => {
|
||||
return (
|
||||
fileStatuses.filter((file) => file.status === "error").length > 0 ||
|
||||
rootError !== undefined
|
||||
);
|
||||
}, [fileStatuses, rootError]);
|
||||
|
||||
const _uploadFile = useCallback(
|
||||
async (file: File, id: string, tries = 0) => {
|
||||
const result = await pOnDropFile(file);
|
||||
|
||||
if (result.status === "error") {
|
||||
if (autoRetry === true && tries < (maxRetryCount ?? Infinity)) {
|
||||
dispatch({type: "update-status", id, status: "pending"});
|
||||
return _uploadFile(file, id, tries + 1);
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: "update-status",
|
||||
id,
|
||||
status: "error",
|
||||
error:
|
||||
pShapeUploadError !== undefined
|
||||
? pShapeUploadError(result.error)
|
||||
: result.error,
|
||||
});
|
||||
if (pOnFileUploadError !== undefined) {
|
||||
pOnFileUploadError(result.error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (pOnFileUploaded !== undefined) {
|
||||
pOnFileUploaded(result.result);
|
||||
}
|
||||
dispatch({
|
||||
type: "update-status",
|
||||
id,
|
||||
...result,
|
||||
});
|
||||
},
|
||||
[
|
||||
autoRetry,
|
||||
maxRetryCount,
|
||||
pOnDropFile,
|
||||
pShapeUploadError,
|
||||
pOnFileUploadError,
|
||||
pOnFileUploaded,
|
||||
],
|
||||
);
|
||||
|
||||
const onRemoveFile = useCallback(
|
||||
async (id: string) => {
|
||||
await pOnRemoveFile?.(id);
|
||||
dispatch({type: "remove", id});
|
||||
},
|
||||
[pOnRemoveFile],
|
||||
);
|
||||
|
||||
const canRetry = useCallback(
|
||||
(id: string) => {
|
||||
const fileStatus = fileStatuses.find((file) => file.id === id);
|
||||
return (
|
||||
fileStatus?.status === "error" &&
|
||||
fileStatus.tries < (maxRetryCount ?? Infinity)
|
||||
);
|
||||
},
|
||||
[fileStatuses, maxRetryCount],
|
||||
);
|
||||
|
||||
const onRetry = useCallback(
|
||||
async (id: string) => {
|
||||
if (!canRetry(id)) {
|
||||
return;
|
||||
}
|
||||
dispatch({type: "update-status", id, status: "pending"});
|
||||
const fileStatus = fileStatuses.find((file) => file.id === id);
|
||||
if (!fileStatus || fileStatus.status !== "error") {
|
||||
return;
|
||||
}
|
||||
await _uploadFile(fileStatus.file, id);
|
||||
},
|
||||
[canRetry, fileStatuses, _uploadFile],
|
||||
);
|
||||
|
||||
const getFileMessageId = (id: string) => `${inputId}-${id}-message`;
|
||||
|
||||
const dropzone = rootUseDropzone({
|
||||
accept: validation?.accept,
|
||||
minSize: validation?.minSize,
|
||||
maxSize: validation?.maxSize,
|
||||
onDropAccepted: async (newFiles) => {
|
||||
setRootError(undefined);
|
||||
|
||||
// useDropzone hook only checks max file count per group of uploaded files, allows going over if in multiple batches
|
||||
const fileCount = fileStatuses.length;
|
||||
const maxNewFiles =
|
||||
validation?.maxFiles === undefined
|
||||
? Infinity
|
||||
: validation?.maxFiles - fileCount;
|
||||
|
||||
if (maxNewFiles < newFiles.length) {
|
||||
if (shiftOnMaxFiles === true) {
|
||||
} else {
|
||||
setRootError(getRootError(["too-many-files"], validation ?? {}));
|
||||
}
|
||||
}
|
||||
|
||||
const slicedNewFiles =
|
||||
shiftOnMaxFiles === true ? newFiles : newFiles.slice(0, maxNewFiles);
|
||||
|
||||
const onDropFilePromises = slicedNewFiles.map(async (file, index) => {
|
||||
if (fileCount + 1 > maxNewFiles) {
|
||||
await onRemoveFile(fileStatuses[index].id);
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
dispatch({type: "add", fileName: file.name, file, id});
|
||||
await _uploadFile(file, id);
|
||||
});
|
||||
|
||||
await Promise.all(onDropFilePromises);
|
||||
if (pOnAllUploaded !== undefined) {
|
||||
pOnAllUploaded();
|
||||
}
|
||||
},
|
||||
onDropRejected: (fileRejections) => {
|
||||
const errorMessage = getRootError(
|
||||
getDropZoneErrorCodes(fileRejections),
|
||||
validation ?? {}
|
||||
);
|
||||
setRootError(errorMessage);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
getRootProps: dropzone.getRootProps,
|
||||
getInputProps: dropzone.getInputProps,
|
||||
inputId,
|
||||
rootMessageId,
|
||||
rootDescriptionId,
|
||||
getFileMessageId,
|
||||
onRemoveFile,
|
||||
onRetry,
|
||||
canRetry,
|
||||
fileStatuses: fileStatuses as FileStatus<TUploadRes, TUploadError>[],
|
||||
isInvalid,
|
||||
rootError,
|
||||
isDragActive: dropzone.isDragActive,
|
||||
};
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const DropZoneContext = createContext<UseDropzoneReturn<any, any>>({
|
||||
getRootProps: () => ({}) as never,
|
||||
getInputProps: () => ({}) as never,
|
||||
onRemoveFile: async () => {
|
||||
},
|
||||
onRetry: async () => {
|
||||
},
|
||||
canRetry: () => false,
|
||||
fileStatuses: [],
|
||||
isInvalid: false,
|
||||
isDragActive: false,
|
||||
rootError: undefined,
|
||||
inputId: "",
|
||||
rootMessageId: "",
|
||||
rootDescriptionId: "",
|
||||
getFileMessageId: () => "",
|
||||
});
|
||||
|
||||
const useDropzoneContext = <TUploadRes, TUploadError>() => {
|
||||
return useContext(DropZoneContext) as UseDropzoneReturn<
|
||||
TUploadRes,
|
||||
TUploadError
|
||||
>;
|
||||
};
|
||||
|
||||
interface DropzoneProps<TUploadRes, TUploadError>
|
||||
extends UseDropzoneReturn<TUploadRes, TUploadError> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Dropzone = <TUploadRes, TUploadError>(
|
||||
props: DropzoneProps<TUploadRes, TUploadError>,
|
||||
) => {
|
||||
const {children, ...rest} = props;
|
||||
return (
|
||||
<DropZoneContext.Provider value={rest}>{children}</DropZoneContext.Provider>
|
||||
);
|
||||
};
|
||||
Dropzone.displayName = "Dropzone";
|
||||
|
||||
interface DropZoneAreaProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
}
|
||||
|
||||
const DropZoneArea = forwardRef<HTMLDivElement, DropZoneAreaProps>(
|
||||
({className, children, ...props}, forwardedRef) => {
|
||||
const context = useDropzoneContext();
|
||||
|
||||
if (!context) {
|
||||
throw new Error("DropzoneArea must be used within a Dropzone");
|
||||
}
|
||||
|
||||
const {onFocus, onBlur, onDragEnter, onDragLeave, onDrop, ref} =
|
||||
context.getRootProps();
|
||||
|
||||
return (
|
||||
// A11y behavior is handled through Trigger. All of these are only relevant to drag and drop which means this should be fine?
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
ref={(instance) => {
|
||||
// TODO: test if this actually works?
|
||||
ref.current = instance;
|
||||
if (typeof forwardedRef === "function") {
|
||||
forwardedRef(instance);
|
||||
} else if (forwardedRef) {
|
||||
forwardedRef.current = instance;
|
||||
}
|
||||
}}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onDragEnter={onDragEnter}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
{...props}
|
||||
aria-label="dropzone"
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
context.isDragActive && "animate-pulse bg-black/5",
|
||||
context.isInvalid && "border-destructive",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
DropZoneArea.displayName = "DropZoneArea";
|
||||
|
||||
export interface DropzoneDescriptionProps
|
||||
extends React.HTMLAttributes<HTMLParagraphElement> {
|
||||
}
|
||||
|
||||
const DropzoneDescription = forwardRef<
|
||||
HTMLParagraphElement,
|
||||
DropzoneDescriptionProps
|
||||
>((props, ref) => {
|
||||
const {className, ...rest} = props;
|
||||
const context = useDropzoneContext();
|
||||
if (!context) {
|
||||
throw new Error("DropzoneDescription must be used within a Dropzone");
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={context.rootDescriptionId}
|
||||
{...rest}
|
||||
className={cn("pb-1 text-sm text-muted-foreground", className)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
DropzoneDescription.displayName = "DropzoneDescription";
|
||||
|
||||
interface DropzoneFileListContext<TUploadRes, TUploadError> {
|
||||
onRemoveFile: () => Promise<void>;
|
||||
onRetry: () => Promise<void>;
|
||||
fileStatus: FileStatus<TUploadRes, TUploadError>;
|
||||
canRetry: boolean;
|
||||
dropzoneId: string;
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
const DropzoneFileListContext = createContext<
|
||||
DropzoneFileListContext<unknown, unknown>
|
||||
>({
|
||||
onRemoveFile: async () => {
|
||||
},
|
||||
onRetry: async () => {
|
||||
},
|
||||
fileStatus: {} as FileStatus<unknown, unknown>,
|
||||
canRetry: false,
|
||||
dropzoneId: "",
|
||||
messageId: "",
|
||||
});
|
||||
|
||||
const useDropzoneFileListContext = () => {
|
||||
return useContext(DropzoneFileListContext);
|
||||
};
|
||||
|
||||
interface DropZoneFileListProps
|
||||
extends React.OlHTMLAttributes<HTMLOListElement> {
|
||||
}
|
||||
|
||||
const DropzoneFileList = forwardRef<HTMLOListElement, DropZoneFileListProps>(
|
||||
(props, ref) => {
|
||||
const context = useDropzoneContext();
|
||||
if (!context) {
|
||||
throw new Error("DropzoneFileList must be used within a Dropzone");
|
||||
}
|
||||
return (
|
||||
<ol
|
||||
ref={ref}
|
||||
aria-label="dropzone-file-list"
|
||||
{...props}
|
||||
className={cn("flex flex-col gap-4", props.className)}
|
||||
>
|
||||
{props.children}
|
||||
</ol>
|
||||
);
|
||||
},
|
||||
);
|
||||
DropzoneFileList.displayName = "DropzoneFileList";
|
||||
|
||||
interface DropzoneFileListItemProps<TUploadRes, TUploadError>
|
||||
extends React.LiHTMLAttributes<HTMLLIElement> {
|
||||
file: FileStatus<TUploadRes, TUploadError>;
|
||||
}
|
||||
|
||||
const DropzoneFileListItem = forwardRef<
|
||||
HTMLLIElement,
|
||||
DropzoneFileListItemProps<unknown, unknown>
|
||||
>(({className, ...props}, ref) => {
|
||||
const fileId = props.file.id;
|
||||
const {
|
||||
onRemoveFile: cOnRemoveFile,
|
||||
onRetry: cOnRetry,
|
||||
getFileMessageId: cGetFileMessageId,
|
||||
canRetry: cCanRetry,
|
||||
inputId: cInputId,
|
||||
} = useDropzoneContext();
|
||||
|
||||
const onRemoveFile = useCallback(
|
||||
() => cOnRemoveFile(fileId),
|
||||
[fileId, cOnRemoveFile],
|
||||
);
|
||||
const onRetry = useCallback(() => cOnRetry(fileId), [fileId, cOnRetry]);
|
||||
const messageId = cGetFileMessageId(fileId);
|
||||
const isInvalid = props.file.status === "error";
|
||||
const canRetry = useMemo(() => cCanRetry(fileId), [fileId, cCanRetry]);
|
||||
return (
|
||||
<DropzoneFileListContext.Provider
|
||||
value={{
|
||||
onRemoveFile,
|
||||
onRetry,
|
||||
fileStatus: props.file,
|
||||
canRetry,
|
||||
dropzoneId: cInputId,
|
||||
messageId,
|
||||
}}
|
||||
>
|
||||
<li
|
||||
ref={ref}
|
||||
aria-label="dropzone-file-list-item"
|
||||
aria-describedby={isInvalid ? messageId : undefined}
|
||||
className={cn(
|
||||
"flex flex-col justify-center gap-2 rounded-md bg-muted/40 px-4 py-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</li>
|
||||
</DropzoneFileListContext.Provider>
|
||||
);
|
||||
});
|
||||
DropzoneFileListItem.displayName = "DropzoneFileListItem";
|
||||
|
||||
interface DropzoneFileMessageProps
|
||||
extends React.HTMLAttributes<HTMLParagraphElement> {
|
||||
}
|
||||
|
||||
const DropzoneFileMessage = forwardRef<
|
||||
HTMLParagraphElement,
|
||||
DropzoneFileMessageProps
|
||||
>((props, ref) => {
|
||||
const {children, ...rest} = props;
|
||||
const context = useDropzoneFileListContext();
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"DropzoneFileMessage must be used within a DropzoneFileListItem",
|
||||
);
|
||||
}
|
||||
|
||||
const body =
|
||||
context.fileStatus.status === "error"
|
||||
? String(context.fileStatus.error)
|
||||
: children;
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={context.messageId}
|
||||
{...rest}
|
||||
className={cn(
|
||||
"h-5 text-[0.8rem] font-medium text-destructive",
|
||||
rest.className,
|
||||
)}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
});
|
||||
DropzoneFileMessage.displayName = "DropzoneFileMessage";
|
||||
|
||||
interface DropzoneMessageProps
|
||||
extends React.HTMLAttributes<HTMLParagraphElement> {
|
||||
}
|
||||
|
||||
const DropzoneMessage = forwardRef<HTMLParagraphElement, DropzoneMessageProps>(
|
||||
(props, ref) => {
|
||||
const {children, ...rest} = props;
|
||||
const context = useDropzoneContext();
|
||||
if (!context) {
|
||||
throw new Error("DropzoneRootMessage must be used within a Dropzone");
|
||||
}
|
||||
|
||||
const body = context.rootError ? String(context.rootError) : children;
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={context.rootMessageId}
|
||||
{...rest}
|
||||
className={cn(
|
||||
"h-5 text-[0.8rem] font-medium text-destructive",
|
||||
rest.className,
|
||||
)}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
},
|
||||
);
|
||||
DropzoneMessage.displayName = "DropzoneMessage";
|
||||
|
||||
interface DropzoneRemoveFileProps extends ButtonVariantsProps {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const DropzoneRemoveFile = forwardRef<
|
||||
HTMLButtonElement,
|
||||
DropzoneRemoveFileProps
|
||||
>(({className, ...props}, ref) => {
|
||||
const context = useDropzoneFileListContext();
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"DropzoneRemoveFile must be used within a DropzoneFileListItem",
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
onClick={context.onRemoveFile}
|
||||
type="button"
|
||||
size="icon"
|
||||
{...props}
|
||||
className={cn(
|
||||
"aria-disabled:pointer-events-none aria-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
<span className="sr-only">Remove file</span>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
DropzoneRemoveFile.displayName = "DropzoneRemoveFile";
|
||||
|
||||
interface DropzoneRetryFileProps extends ButtonVariantsProps {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const DropzoneRetryFile = forwardRef<HTMLButtonElement, DropzoneRetryFileProps>(
|
||||
({className, ...props}, ref) => {
|
||||
const context = useDropzoneFileListContext();
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"DropzoneRetryFile must be used within a DropzoneFileListItem",
|
||||
);
|
||||
}
|
||||
|
||||
const canRetry = context.canRetry;
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
aria-disabled={!canRetry}
|
||||
aria-label="retry"
|
||||
onClick={context.onRetry}
|
||||
type="button"
|
||||
size="icon"
|
||||
{...props}
|
||||
className={cn(
|
||||
"aria-disabled:pointer-events-none aria-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
<span className="sr-only">Retry</span>
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
);
|
||||
DropzoneRetryFile.displayName = "DropzoneRetryFile";
|
||||
|
||||
interface DropzoneTriggerProps
|
||||
extends React.LabelHTMLAttributes<HTMLLabelElement> {
|
||||
}
|
||||
|
||||
const DropzoneTrigger = forwardRef<HTMLLabelElement, DropzoneTriggerProps>(
|
||||
({className, children, ...props}, ref) => {
|
||||
const context = useDropzoneContext();
|
||||
if (!context) {
|
||||
throw new Error("DropzoneTrigger must be used within a Dropzone");
|
||||
}
|
||||
|
||||
const {fileStatuses, getFileMessageId} = context;
|
||||
|
||||
const fileMessageIds = useMemo(
|
||||
() =>
|
||||
fileStatuses
|
||||
.filter((file) => file.status === "error")
|
||||
.map((file) => getFileMessageId(file.id)),
|
||||
[fileStatuses, getFileMessageId],
|
||||
);
|
||||
|
||||
return (
|
||||
<label
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={cn(
|
||||
"cursor-pointer rounded-sm bg-secondary px-4 py-2 font-medium ring-offset-background transition-colors focus-within:outline-none hover:bg-secondary/80 has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-ring has-[input:focus-visible]:ring-offset-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<input
|
||||
{...context.getInputProps({
|
||||
style: {
|
||||
display: undefined,
|
||||
},
|
||||
className: "sr-only",
|
||||
tabIndex: undefined,
|
||||
})}
|
||||
aria-describedby={
|
||||
context.isInvalid
|
||||
? [context.rootMessageId, ...fileMessageIds].join(" ")
|
||||
: undefined
|
||||
}
|
||||
aria-invalid={context.isInvalid}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
},
|
||||
);
|
||||
DropzoneTrigger.displayName = "DropzoneTrigger";
|
||||
|
||||
interface InfiniteProgressProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
status: "pending" | "success" | "error";
|
||||
}
|
||||
|
||||
const valueTextMap = {
|
||||
pending: "indeterminate",
|
||||
success: "100%",
|
||||
error: "error",
|
||||
};
|
||||
|
||||
const InfiniteProgress = forwardRef<HTMLDivElement, InfiniteProgressProps>(
|
||||
({className, ...props}, ref) => {
|
||||
const done = props.status === "success" || props.status === "error";
|
||||
const error = props.status === "error";
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuetext={valueTextMap[props.status]}
|
||||
{...props}
|
||||
className={cn(
|
||||
"relative h-2 w-full overflow-hidden rounded-full bg-muted",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
// TODO: add proper done transition
|
||||
className={cn(
|
||||
"h-full w-full rounded-full bg-primary",
|
||||
done ? "translate-x-0" : "animate-infinite-progress",
|
||||
error && "bg-destructive",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
InfiniteProgress.displayName = "InfiniteProgress";
|
||||
|
||||
export {
|
||||
Dropzone,
|
||||
DropZoneArea,
|
||||
DropzoneDescription,
|
||||
DropzoneFileList,
|
||||
DropzoneFileListItem,
|
||||
DropzoneFileMessage,
|
||||
DropzoneMessage,
|
||||
DropzoneRemoveFile,
|
||||
DropzoneRetryFile,
|
||||
DropzoneTrigger,
|
||||
InfiniteProgress,
|
||||
useDropzone,
|
||||
};
|
||||
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import {Ref, useEffect, useState} from "react";
|
||||
import {Check, X} from "lucide-react";
|
||||
import {motion, AnimatePresence} from "framer-motion";
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
|
||||
interface PasswordStrengthInputProps {
|
||||
field: {
|
||||
name: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onBlur: () => void;
|
||||
ref: Ref<HTMLInputElement>;
|
||||
};
|
||||
label?: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
onValidChange?: (isValid: boolean) => void;
|
||||
|
||||
}
|
||||
|
||||
const passwordTextsFields = {
|
||||
label: "Password",
|
||||
placeholder: "Enter password",
|
||||
strength: {
|
||||
enter: "Enter a password",
|
||||
weak: "Weak password",
|
||||
medium: "Medium password",
|
||||
strong: "Strong password"
|
||||
},
|
||||
requirementsIntro: "Must contain:",
|
||||
requirements: {
|
||||
minLength: "At least 8 characters",
|
||||
number: "At least 1 number",
|
||||
lowercase: "At least 1 lowercase letter",
|
||||
uppercase: "At least 1 uppercase letter",
|
||||
specialChar: "At least 1 special character"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function PasswordStrengthInput({
|
||||
field,
|
||||
label,
|
||||
description,
|
||||
disabled, onValidChange
|
||||
}: PasswordStrengthInputProps) {
|
||||
const text = passwordTextsFields
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const password = field.value ?? "";
|
||||
|
||||
const requirements = [
|
||||
{regex: /.{8,}/, text: text.requirements.minLength},
|
||||
{regex: /[0-9]/, text: text.requirements.number},
|
||||
{regex: /[a-z]/, text: text.requirements.lowercase},
|
||||
{regex: /[A-Z]/, text: text.requirements.uppercase},
|
||||
{regex: /[^a-zA-Z0-9]/, text: text.requirements.specialChar},
|
||||
];
|
||||
|
||||
const strength = requirements.map((req) => ({
|
||||
met: req.regex.test(password),
|
||||
text: req.text,
|
||||
}));
|
||||
|
||||
const strengthScore = strength.filter((req) => req.met).length;
|
||||
|
||||
const getStrengthColor = (score: number) => {
|
||||
if (score === 0) return "bg-border";
|
||||
if (score <= 2) return "bg-red-500";
|
||||
if (score === 3) return "bg-orange-500";
|
||||
if (score === 4) return "bg-amber-500";
|
||||
return "bg-emerald-500";
|
||||
};
|
||||
|
||||
const getStrengthText = (score: number) => {
|
||||
if (score === 0) return text.strength.enter;
|
||||
if (score <= 2) return text.strength.weak;
|
||||
if (score === 3 || score === 4) return text.strength.medium;
|
||||
return text.strength.strong;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (strengthScore === requirements.length) {
|
||||
onValidChange?.(true);
|
||||
} else {
|
||||
onValidChange?.(false);
|
||||
}
|
||||
}, [strengthScore, onValidChange]);
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>{label ?? text.label}</FormLabel>
|
||||
<div className="relative">
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
placeholder={text.placeholder}
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
onFocus={() => setIsVisible(true)}
|
||||
onBlur={(e) => {
|
||||
field.onBlur();
|
||||
setIsVisible(false);
|
||||
}}
|
||||
ref={field.ref}
|
||||
name={field.name}
|
||||
disabled={disabled}
|
||||
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isVisible && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, height: 0, y: -5}}
|
||||
animate={{opacity: 1, height: "auto", y: 0}}
|
||||
exit={{opacity: 0, height: 0, y: -5}}
|
||||
transition={{duration: 0.35, ease: "easeInOut"}}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<FormDescription>
|
||||
{description ??
|
||||
`${getStrengthText(strengthScore)}. ${text.requirementsIntro}`}
|
||||
</FormDescription>
|
||||
<FormMessage/>
|
||||
|
||||
<div
|
||||
className="mb-2 h-1 w-full overflow-hidden rounded-full bg-border mt-2"
|
||||
role="progressbar"
|
||||
aria-valuenow={strengthScore}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={requirements.length}
|
||||
>
|
||||
<motion.div
|
||||
key={strengthScore}
|
||||
className={`h-full ${getStrengthColor(
|
||||
strengthScore
|
||||
)}`}
|
||||
initial={{width: 0}}
|
||||
animate={{
|
||||
width: `${(strengthScore / requirements.length) * 100}%`,
|
||||
}}
|
||||
transition={{duration: 0.5}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-1.5" aria-label="Password requirements">
|
||||
{strength.map((req, i) => (
|
||||
<li key={i} className="flex items-center gap-2">
|
||||
{req.met ? (
|
||||
<Check className="h-4 w-4 text-emerald-500"/>
|
||||
) : (
|
||||
<X className="h-4 w-4 text-muted-foreground"/>
|
||||
)}
|
||||
<span
|
||||
className={`text-xs ${
|
||||
req.met ? "text-emerald-600" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{req.text}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +1,49 @@
|
||||
"use client"
|
||||
import {env} from "@/env.mjs";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {useTheme} from "next-themes";
|
||||
"use client";
|
||||
|
||||
import {env} from "@/env.mjs";
|
||||
import {useTheme} from "next-themes";
|
||||
import Image from "next/image";
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
export const AuthLogoSection = () => {
|
||||
|
||||
const {resolvedTheme} = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
const imageTheme =
|
||||
resolvedTheme === "dark"
|
||||
? "/images/logo-dark.png"
|
||||
: "/images/logo-light.png";
|
||||
|
||||
const handleLoad = () => setLoaded(true);
|
||||
|
||||
const style = {
|
||||
transition: "opacity 0.3s ease-in-out",
|
||||
opacity: loaded ? 1 : 0,
|
||||
};
|
||||
|
||||
const imageTheme = resolvedTheme === "dark" ? "/images/logo-white.png" : "/images/logo-black.png";
|
||||
|
||||
return (
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md flex items-center justify-center space-x-2">
|
||||
<img
|
||||
className="p-12 text-black dark:text-white"
|
||||
src={imageTheme}
|
||||
alt="Logo"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground -ml-12 -mb-12">v{env.NEXT_PUBLIC_PROJECT_VERSION}</span>
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md relative flex items-center justify-center h-[160px]">
|
||||
{mounted && (
|
||||
<Image
|
||||
src={imageTheme}
|
||||
alt="Logo"
|
||||
fill
|
||||
priority
|
||||
className="object-contain p-10"
|
||||
onLoad={handleLoad}
|
||||
style={style}
|
||||
|
||||
/>
|
||||
)}
|
||||
<span className="absolute bottom-10 right-5 text-sm text-muted-foreground" style={style}>
|
||||
v{env.NEXT_PUBLIC_PROJECT_VERSION}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import TwoFactorForm from "@/components/wrappers/dashboard/profile/form/2fa-form";
|
||||
|
||||
export const GuardForm = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const callbackUrl = searchParams.get("redirect") || "/dashboard";
|
||||
|
||||
return (
|
||||
<TwoFactorForm
|
||||
onSuccess={(success) => {
|
||||
if (success) {
|
||||
toast.success("Successfully logged in!");
|
||||
//@ts-ignore
|
||||
router.push(callbackUrl);
|
||||
router.refresh();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
"use client";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {signIn} from "@/lib/auth/auth-client";
|
||||
import {JSX} from "react";
|
||||
|
||||
export type AuthButtonProps = {
|
||||
providers: SocialProviderType[];
|
||||
callBackURL?: string;
|
||||
};
|
||||
|
||||
export type SocialProviderType = {
|
||||
id:
|
||||
| "github"
|
||||
| "apple"
|
||||
| "discord"
|
||||
| "facebook"
|
||||
| "microsoft"
|
||||
| "google"
|
||||
| "spotify"
|
||||
| "twitch"
|
||||
| "twitter"
|
||||
| "dropbox"
|
||||
| "kick"
|
||||
| "linkedin"
|
||||
| "gitlab"
|
||||
| "tiktok"
|
||||
| "reddit"
|
||||
| "roblox"
|
||||
| "vk";
|
||||
name: string;
|
||||
icon: JSX.Element;
|
||||
};
|
||||
|
||||
export const SocialAuthButton = (props: AuthButtonProps): JSX.Element => {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 mt-5">
|
||||
{props.providers.map((provider) => (
|
||||
<Button
|
||||
key={provider.id}
|
||||
aria-label={`Sign in with ${provider.name}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void signIn.social({
|
||||
provider: provider.id,
|
||||
callbackURL: props.callBackURL ?? "/dashboard/profile",
|
||||
});
|
||||
}}
|
||||
>
|
||||
{provider.icon}
|
||||
Sign in with {provider.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { z } from "zod";
|
||||
import { zEmail } from "@/lib/zod";
|
||||
|
||||
export const ForgotPasswordSchema = z.object({
|
||||
email: zEmail(),
|
||||
});
|
||||
|
||||
export type ForgotPasswordType = z.infer<typeof ForgotPasswordSchema>;
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { requestPasswordReset } from "@/lib/auth/auth-client";
|
||||
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
|
||||
import Link from "next/link";
|
||||
import { ForgotPasswordSchema, ForgotPasswordType } from "./forgot-password-form.schema";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
export type ForgotPasswordFormProps = {
|
||||
defaultValues?: ForgotPasswordType;
|
||||
};
|
||||
|
||||
export const ForgotPasswordForm = (props: ForgotPasswordFormProps) => {
|
||||
|
||||
const form = useZodForm({
|
||||
schema: ForgotPasswordSchema,
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: ForgotPasswordType) => {
|
||||
await requestPasswordReset(
|
||||
{
|
||||
email: values.email,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("If an account with this email address exists, you will receive an email with instructions to reset your password.");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.error.message);
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4 mb-1"
|
||||
onSubmit={async (values) => {
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input autoComplete="email" autoFocus
|
||||
placeholder="example@portabase.io"
|
||||
{...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col items-center gap-y-6 w-full">
|
||||
<ButtonWithLoading className="mt-2 w-full" isPending={mutation.isPending}>
|
||||
Send reset link
|
||||
</ButtonWithLoading>
|
||||
<Link href="/login" className="group flex items-center text-sm hover:underline">
|
||||
<ArrowLeft className="mr-1 size-4 text-muted-foreground transition-transform group-hover:-translate-x-1" />
|
||||
Back to login
|
||||
</Link>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
"use server";
|
||||
import { ServerActionResult } from "@/types/action-type";
|
||||
import { auth } from "@/lib/auth/auth";
|
||||
import { zString } from "@/lib/zod";
|
||||
import z from "zod";
|
||||
import { db } from "@/db";
|
||||
import {action} from "@/lib/safe-actions/actions";
|
||||
|
||||
//todo: to be continued...
|
||||
export const forgotPasswordAction = action
|
||||
.schema(
|
||||
z.object({
|
||||
schema: z.object({
|
||||
email: zString(),
|
||||
}),
|
||||
redirectTo: zString().optional(),
|
||||
})
|
||||
)
|
||||
.action(async ({ parsedInput }): Promise<ServerActionResult<null>> => {
|
||||
try {
|
||||
const user = await (await auth.$context).internalAdapter.findUserByEmail(parsedInput.schema.email);
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "password_reset",
|
||||
cause: "user_not_found",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const existingToken = await db.query.verification.findFirst({
|
||||
where: (verifications, { eq, and, gte }) =>
|
||||
and(eq(verifications.value, user.user.id), gte(verifications.expiresAt, new Date(Date.now() + 15 * 60 * 1000))),
|
||||
});
|
||||
|
||||
if (existingToken) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "password_reset",
|
||||
cause: "reset_already_requested",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// await (
|
||||
// await auth.$context
|
||||
// ).options.emailAndPassword
|
||||
// .sendResetPassword(
|
||||
// {
|
||||
// user: user.user,
|
||||
// url,
|
||||
// token: verificationToken,
|
||||
// },
|
||||
// ctx.request
|
||||
// )
|
||||
// .catch((e) => {
|
||||
// ctx.context.logger.error("Failed to send reset password email", e);
|
||||
// });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "password_reset",
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { z } from "zod";
|
||||
import { zEmail, zString } from "@/lib/zod";
|
||||
|
||||
export const LoginSchema = z.object({
|
||||
email: z.string(),
|
||||
password: z.string(),
|
||||
email: zEmail(),
|
||||
password: zString().nonempty(),
|
||||
});
|
||||
|
||||
export type LoginType = z.infer<typeof LoginSchema>;
|
||||
|
||||
@@ -1,308 +1,123 @@
|
||||
// "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";
|
||||
import {useEffect, useState} from "react";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {toast} from "sonner";
|
||||
|
||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Form} from "@/components/ui/form";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {toast} from "sonner";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
||||
import Link from "next/link";
|
||||
import {PasswordInput} from "@/components/wrappers/auth/password-input/password-input";
|
||||
import {LoginSchema, LoginType} from "@/components/wrappers/auth/login/login-form/login-form.schema";
|
||||
import {SocialAuthButton, SocialProviderType} from "@/components/wrappers/auth/login/button-auth/social-auth-button";
|
||||
import {signIn} from "@/lib/auth/auth-client";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import Link from "next/link";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {Icon} from "@iconify/react";
|
||||
import {useEffect, useState} from "react";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
|
||||
export type loginFormProps = {
|
||||
defaultValues?: LoginType;
|
||||
authGoogleEnabled: boolean;
|
||||
};
|
||||
|
||||
export const LoginForm = (props: loginFormProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const form = useZodForm({
|
||||
schema: LoginSchema,
|
||||
});
|
||||
|
||||
const [urlParams] = useState(() =>
|
||||
new URLSearchParams(typeof window !== "undefined" ? window.location.search : "")
|
||||
);
|
||||
const [urlParams, setUrlParams] = useState<URLSearchParams>();
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
setUrlParams(urlParams);
|
||||
const error = urlParams.get("error");
|
||||
if (error?.includes("pending")) {
|
||||
toast.error("Your account is not active.");
|
||||
urlParams.delete("error");
|
||||
window.history.replaceState({}, document.title, window.location.pathname + "?" + urlParams.toString());
|
||||
}
|
||||
}, [urlParams]);
|
||||
if (error?.includes("invalid_or_expired_token")) {
|
||||
toast.error("Password reset invalid token.");
|
||||
urlParams.delete("error");
|
||||
window.history.replaceState({}, document.title, window.location.pathname + "?" + urlParams.toString());
|
||||
}
|
||||
}, []);
|
||||
|
||||
const form = useZodForm({
|
||||
schema: LoginSchema,
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: LoginType) => {
|
||||
try {
|
||||
const callbackURL =
|
||||
urlParams.get("redirect")?.startsWith("/")
|
||||
? urlParams.get("redirect")
|
||||
: "/dashboard/profile";
|
||||
|
||||
const {error} = await signIn.email({
|
||||
email: values.email,
|
||||
await signIn.email(
|
||||
{
|
||||
password: values.password,
|
||||
callbackURL: callbackURL ?? "/dashboard/profile",
|
||||
});
|
||||
email: values.email,
|
||||
callbackURL: urlParams?.get("redirect") ?? "/dashboard",
|
||||
},
|
||||
{
|
||||
onSuccess: (context) => {
|
||||
if (context.data.twoFactorRedirect) {
|
||||
//@ts-ignore
|
||||
router.push("/guard?redirect=" + encodeURIComponent(context.data.callbackURL || "/dashboard"));
|
||||
}
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
toast.success("Login success");
|
||||
toast.success("Login success");
|
||||
},
|
||||
onError: (error) => {
|
||||
console.log(error);
|
||||
toast.error(error.error.message);
|
||||
},
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error("Unexpected client error during login");
|
||||
}
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.message || "Client error");
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
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 information 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"
|
||||
placeholder="example@portabase.io"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
defaultValue=""
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center">
|
||||
<FormLabel>Password</FormLabel>
|
||||
{/* Optional forgot password link */}
|
||||
</div>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
autoComplete="current-password"
|
||||
placeholder="Your password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<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>
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4 mb-1"
|
||||
onSubmit={async (values) => {
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
defaultValue=""
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input autoComplete="email" autoFocus placeholder="exemple@portabase.io" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
defaultValue=""
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Password</FormLabel>
|
||||
<div className="text-center text-sm">
|
||||
<Link href={"/forgot-password"} className="hover:underline ml-1">
|
||||
Forgot your password ?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<SocialAuthButton
|
||||
callBackURL={urlParams.get("redirect") ?? "/dashboard/profile"}
|
||||
providers={availableProviders}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TooltipProvider>
|
||||
<FormControl>
|
||||
<PasswordInput autoComplete="current-password webauthn"
|
||||
placeholder={"Enter your password"} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<ButtonWithLoading className="mt-2" isPending={mutation.isPending}>
|
||||
Login
|
||||
</ButtonWithLoading>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"use server";
|
||||
import { ServerActionResult } from "@/types/action-type";
|
||||
import { auth } from "@/lib/auth/auth";
|
||||
import { zPassword, zString } from "@/lib/zod";
|
||||
import z from "zod";
|
||||
import {action} from "@/lib/safe-actions/actions";
|
||||
|
||||
export const resetPasswordAction = action
|
||||
.schema(
|
||||
z.object({
|
||||
schema: z.object({
|
||||
password: zPassword(),
|
||||
}),
|
||||
token: zString(),
|
||||
})
|
||||
)
|
||||
.action(async ({ parsedInput }): Promise<ServerActionResult<null>> => {
|
||||
try {
|
||||
const verification = await (await auth.$context).internalAdapter.findVerificationValue(`reset-password:${parsedInput.token}`);
|
||||
if (!verification || verification.expiresAt < new Date()) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "password_reset",
|
||||
cause: "invalid_or_expired_token",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const user = await (await auth.$context).internalAdapter.findUserById(verification.value);
|
||||
console.log(user)
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "password_reset",
|
||||
cause: "user_not_found",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const hashedPassword = await (await auth.$context).password.hash(parsedInput.schema.password);
|
||||
console.log(hashedPassword)
|
||||
console.log("ok")
|
||||
// await (await auth.$context).internalAdapter.updatePassword(user.id, hashedPassword);
|
||||
console.log("ici")
|
||||
// await (await auth.$context).internalAdapter.deleteSessions(user.id);
|
||||
// console.log("ici2")
|
||||
// await (await auth.$context).internalAdapter.deleteVerificationValue(verification.id);
|
||||
// console.log("ici3");
|
||||
//
|
||||
// (await auth.$context).internalAdapter.updateUser(user.id, {
|
||||
// lastChangedPasswordAt: new Date(),
|
||||
// });
|
||||
|
||||
// await auth.api.resetPassword({
|
||||
// headers: await headers(),
|
||||
// body: {
|
||||
// newPassword: parsedInput.schema.password,
|
||||
// token: parsedInput.token,
|
||||
// },
|
||||
// });
|
||||
|
||||
await (
|
||||
await auth.$context
|
||||
).internalAdapter.updateUser(user.id, {
|
||||
isDefaultPassword: false,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
actionSuccess: {
|
||||
message: "password_reset",
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "password_reset",
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import {z} from "zod";
|
||||
import {zPassword} from "@/lib/zod";
|
||||
|
||||
export const ResetPasswordSchema = z
|
||||
.object({
|
||||
password: zPassword(),
|
||||
confirmPassword: zPassword(),
|
||||
})
|
||||
.superRefine(({confirmPassword, password}, ctx) => {
|
||||
if (confirmPassword !== password) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Confirmation password does not match",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type ResetPasswordType = z.infer<typeof ResetPasswordSchema>;
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {toast} from "sonner";
|
||||
|
||||
import {FormControl, FormField, FormItem, FormLabel, useZodForm} from "@/components/ui/form";
|
||||
import {Form} from "@/components/ui/form";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import Link from "next/link";
|
||||
import {ResetPasswordSchema, ResetPasswordType} from "./reset-password-form.schema";
|
||||
import {PasswordStrengthInput} from "@/components/ui/password-input-indicator";
|
||||
import {useRouter, useSearchParams} from "next/navigation";
|
||||
import {ArrowLeft} from "lucide-react";
|
||||
import {authClient} from "@/lib/auth/auth-client";
|
||||
import {BetterAuthError} from "@/types/auth";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
|
||||
export type ResetPasswordFormProps = {
|
||||
defaultValues?: ResetPasswordType;
|
||||
};
|
||||
|
||||
export const ResetPasswordForm = (props: ResetPasswordFormProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const form = useZodForm({
|
||||
schema: ResetPasswordSchema,
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: ResetPasswordType) => {
|
||||
|
||||
const {data, error} = await authClient.resetPassword({
|
||||
newPassword: values.password,
|
||||
token: searchParams.get("token") || "",
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Password successfully reset!");
|
||||
setTimeout(() => router.push("/"), 1400);
|
||||
},
|
||||
onError: (error: BetterAuthError) => {
|
||||
console.log(error)
|
||||
toast.error("An error occurred while resetting password");
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4 mb-1"
|
||||
onSubmit={async (values) => {
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
defaultValue=""
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<PasswordStrengthInput label={"New password"} field={field}/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmPassword"
|
||||
defaultValue=""
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Confirmation password</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput placeholder={"Enter your conformation password"} {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col items-center gap-y-6 w-full">
|
||||
<ButtonWithLoading className="mt-2 w-full" isPending={mutation.isPending}>
|
||||
Reset
|
||||
</ButtonWithLoading>
|
||||
<Link href="/login" className="group flex items-center text-sm hover:underline">
|
||||
<ArrowLeft
|
||||
className="mr-1 size-4 text-muted-foreground transition-transform group-hover:-translate-x-1"/>
|
||||
Back to login
|
||||
</Link>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -1,19 +1,21 @@
|
||||
"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";
|
||||
import {CardAuth} from "@/features/layout/card-auth";
|
||||
|
||||
export type registerFormProps = {
|
||||
defaultValues?: RegisterType;
|
||||
@@ -29,7 +31,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`);
|
||||
},
|
||||
@@ -44,7 +46,7 @@ export const RegisterForm = (props: registerFormProps) => {
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Card>
|
||||
<CardAuth>
|
||||
<CardHeader>
|
||||
<div className="grid gap-2 text-center mb-2">
|
||||
<h1 className="text-3xl font-bold">Create an account</h1>
|
||||
@@ -63,13 +65,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 +79,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 +93,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 +115,7 @@ export const RegisterForm = (props: registerFormProps) => {
|
||||
<FormControl>
|
||||
<PasswordInput placeholder="Your password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -120,20 +123,29 @@ 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>
|
||||
</CardAuth>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"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";
|
||||
import {CardAuth} from "@/features/layout/card-auth";
|
||||
|
||||
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 (
|
||||
<CardAuth>
|
||||
<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>
|
||||
</CardAuth>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import {z} from "zod";
|
||||
|
||||
const passwordRegex = /^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-])/;
|
||||
|
||||
const zPassword = () => z.string().min(8, {message: "New Password too short"}).regex(passwordRegex, {message: "New Password too weak"});
|
||||
|
||||
export const ResetPasswordSchema = z
|
||||
.object({
|
||||
password: zPassword(),
|
||||
confirmPassword: zPassword(),
|
||||
})
|
||||
.superRefine(({confirmPassword, password}, ctx) => {
|
||||
if (confirmPassword !== password) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Passwords don't match.",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type ResetPasswordType = z.infer<typeof ResetPasswordSchema>;
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client"
|
||||
|
||||
import {useRouter, useSearchParams} from "next/navigation";
|
||||
import {ResetPasswordForm} from "@/components/wrappers/auth/reset-password/reset-password-form";
|
||||
import {toast} from "sonner";
|
||||
import {useEffect} from "react";
|
||||
import {LoadingSpinner} from "@/components/wrappers/common/loading/loading-spinner";
|
||||
|
||||
export const ResetPasswordSection = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const token = searchParams.get("token");
|
||||
const error = searchParams.get("error");
|
||||
|
||||
useEffect(() => {
|
||||
if (error || !token) {
|
||||
if (error == "INVALID_TOKEN") {
|
||||
toast.error("Invalid reset token");
|
||||
} else {
|
||||
toast.error("An error occurred.");
|
||||
}
|
||||
setTimeout(() => router.push("/"), 1000);
|
||||
}
|
||||
}, [error, token, router]);
|
||||
|
||||
|
||||
if (!token) return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center">
|
||||
<LoadingSpinner size={50}/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<ResetPasswordForm token={token}/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { authClient } from "@/lib/auth/auth-client";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {AuthProviderConfig} from "../../../../portabase.config";
|
||||
import {Icon} from "@iconify/react";
|
||||
|
||||
export function SocialAuthButtons({ providers }: { providers: AuthProviderConfig[] }) {
|
||||
const socialProviders = providers.filter(p => p.isActive && !p.isManual);
|
||||
|
||||
const [isLoading, setIsLoading] = useState<string | null>(null);
|
||||
|
||||
const handleSocialSignIn = async (providerId: string) => {
|
||||
setIsLoading(providerId);
|
||||
try {
|
||||
const { error } = await authClient.signIn.social({
|
||||
provider: providerId as "google" | "github",
|
||||
callbackURL: "/dashboard",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error("An error occurred while signing in with the provider. Please try again.");
|
||||
} else {
|
||||
toast.success("Redirecting to provider...");
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error("An error occurred while signing in with the provider. Please try again.");
|
||||
} finally {
|
||||
setIsLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (socialProviders.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{socialProviders.map((provider) => (
|
||||
<Button key={provider.id} variant="outline" className="w-full gap-2" onClick={() => handleSocialSignIn(provider.id)} disabled={!!isLoading}>
|
||||
{isLoading === provider.id ? <Loader2 className="h-4 w-4 animate-spin" /> :
|
||||
<Icon icon={provider.icon} className="h-4 w-4"/>
|
||||
}
|
||||
<span>{PROVIDERS_TEXT[provider.id].title}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const PROVIDERS_TEXT = {
|
||||
credential: {
|
||||
title: "Password",
|
||||
description: "Use your email address and password to sign in."
|
||||
},
|
||||
google: {
|
||||
title: "Google",
|
||||
description: "Sign in with your Google account."
|
||||
},
|
||||
github: {
|
||||
title: "GitHub",
|
||||
description: "Sign in with your GitHub account."
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ export function BreadCrumbsWrapper() {
|
||||
)
|
||||
}
|
||||
|
||||
const FORBIDDEN_LINKS = ["organization", "dashboard", "database"];
|
||||
const FORBIDDEN_LINKS = ["organization", "dashboard", "database", "admin", "settings", "notifications"];
|
||||
|
||||
|
||||
export function BreadCrumbs({}: BreadCrumbsProps) {
|
||||
|
||||
@@ -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,81 +6,87 @@ 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..."}
|
||||
<ChevronDown className="ml-auto"/>
|
||||
<label className="max-w-[170px] truncate">
|
||||
{value ? choices.find((c) => c.value === value)?.label : "Select choice..."}
|
||||
</label>
|
||||
<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..."}
|
||||
<label className="max-w-[170px] truncate">
|
||||
{value ? choices.find((c) => c.value === value)?.label : "Select choice..."}
|
||||
</label>
|
||||
<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}
|
||||
<label className="max-w-[170px] truncate">
|
||||
{choice.label}
|
||||
</label>
|
||||
<Check
|
||||
className={cn("ml-auto", value === choice.value ? "opacity-100" : "opacity-0")}/>
|
||||
</CommandItem>
|
||||
@@ -88,66 +94,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)}/>;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CircleCheck,
|
||||
CircleX,
|
||||
CloudUploadIcon,
|
||||
Loader2,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropZoneArea,
|
||||
DropzoneDescription,
|
||||
DropzoneFileList,
|
||||
DropzoneFileListItem,
|
||||
DropzoneMessage,
|
||||
DropzoneRemoveFile,
|
||||
DropzoneTrigger,
|
||||
useDropzone,
|
||||
Dropzone,
|
||||
} from "@/components/ui/dropzone";
|
||||
|
||||
type DropZoneFileProps = {
|
||||
onFileDropAction?: (file: File) => Promise<void> | void;
|
||||
onFileRemoveAction?: (file: File) => void;
|
||||
accept?: Record<string, string[]>;
|
||||
maxSize?: number;
|
||||
maxFiles?: number;
|
||||
description?: string;
|
||||
fileKind?: string;
|
||||
dragMessage?: string;
|
||||
fileList?: boolean;
|
||||
};
|
||||
|
||||
|
||||
export const DropZoneFile = ({
|
||||
onFileDropAction,
|
||||
onFileRemoveAction,
|
||||
accept = {"application/pdf": [".pdf"]},
|
||||
maxSize = 50 * 1024 * 1024,
|
||||
maxFiles = 1,
|
||||
description = "Please select a file",
|
||||
fileKind = "Upload files (.pdf)",
|
||||
dragMessage = "Click here or drag & drop to upload",
|
||||
fileList = true
|
||||
}: DropZoneFileProps) => {
|
||||
const dropzone = useDropzone({
|
||||
onDropFile: async (file: File) => {
|
||||
onFileDropAction?.(file);
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
result: URL.createObjectURL(file),
|
||||
};
|
||||
},
|
||||
validation: {
|
||||
accept,
|
||||
maxSize,
|
||||
maxFiles,
|
||||
},
|
||||
});
|
||||
|
||||
const handleRemove = (file: any) => {
|
||||
dropzone.onRemoveFile(file.id);
|
||||
onFileRemoveAction?.(file.file);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="not-prose flex flex-col gap-4 h-full">
|
||||
<Dropzone {...dropzone}>
|
||||
<div className="h-full">
|
||||
{dropzone.fileStatuses.length < maxFiles && (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex justify-between">
|
||||
<DropzoneDescription>{description}</DropzoneDescription>
|
||||
<DropzoneMessage/>
|
||||
</div>
|
||||
<DropZoneArea className="flex-1 px-0 py-0 mt-2">
|
||||
<DropzoneTrigger
|
||||
className="flex h-full w-full flex-col items-center justify-center gap-4 bg-transparent p-10 text-center text-sm">
|
||||
<CloudUploadIcon className="size-8"/>
|
||||
<div>
|
||||
<p className="font-semibold">{fileKind}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{dragMessage}
|
||||
</p>
|
||||
</div>
|
||||
</DropzoneTrigger>
|
||||
</DropZoneArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{fileList && (
|
||||
<DropzoneFileList className="grid gap-3 p-0">
|
||||
{dropzone.fileStatuses.map((file) => (
|
||||
<DropzoneFileListItem
|
||||
key={file.id}
|
||||
file={file}
|
||||
className="overflow-hidden rounded-md bg-secondary p-0 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between p-2 pl-4">
|
||||
<div className="flex overflow-hidden items-center gap-3">
|
||||
<div>
|
||||
{file.status === "pending" ? (
|
||||
<Loader2 className="animate-spin" size={16}/>
|
||||
) : file.status === "error" ? (
|
||||
<CircleX className="text-red-600" size={16}/>
|
||||
) : (
|
||||
<CircleCheck className="text-green-700" size={16}/>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm flex">
|
||||
<span>{file.fileName}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(file.file.size / (1024 * 1024)).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DropzoneRemoveFile
|
||||
variant="ghost"
|
||||
className="shrink-0 hover:outline"
|
||||
// @ts-ignore
|
||||
onClick={() => handleRemove(file)}
|
||||
>
|
||||
<Trash2Icon className="size-4"/>
|
||||
</DropzoneRemoveFile>
|
||||
</div>
|
||||
</DropzoneFileListItem>
|
||||
))}
|
||||
</DropzoneFileList>
|
||||
)}
|
||||
|
||||
</Dropzone>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -4,28 +4,38 @@ import {Plus} from "lucide-react";
|
||||
|
||||
type EmptyStatePlaceholderProps = {
|
||||
url?: string;
|
||||
onClick?: () => void;
|
||||
text: string;
|
||||
}
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const EmptyStatePlaceholder = ({url, text}: EmptyStatePlaceholderProps) => {
|
||||
return (
|
||||
<>{url ?
|
||||
<Link
|
||||
href={url}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center w-full rounded-2xl border border-dashed border-muted p-6 lg:p-10",
|
||||
"hover:bg-muted/50 transition-colors text-muted-foreground hover:text-primary text-center space-y-2"
|
||||
)}
|
||||
>
|
||||
<Plus className="w-5 h-5 lg:w-6 lg:h-6"/>
|
||||
<span className="text-sm lg:text-base font-medium">{text}</span>
|
||||
</Link>
|
||||
:
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<p className="text-lg text-muted-foreground">{text}</p>
|
||||
export const EmptyStatePlaceholder = ({
|
||||
url,
|
||||
onClick,
|
||||
text,
|
||||
className,
|
||||
}: EmptyStatePlaceholderProps) => {
|
||||
const Container = (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full flex-col items-center justify-center w-full rounded-2xl border border-dashed border-muted p-6 lg:p-10",
|
||||
"hover:bg-muted/50 transition-colors text-muted-foreground hover:text-primary text-center space-y-4",
|
||||
(onClick || url) && "cursor-pointer"
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Plus className="w-5 h-5 lg:w-6 lg:h-6" />
|
||||
<p className="text-sm">{text}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (url) {
|
||||
return (
|
||||
<div className={cn(className)}>
|
||||
<Link href={url}>{Container}</Link>
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
)
|
||||
}
|
||||
return <div className={cn(className)}>{Container}</div>;
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ export const GitHubStarsButtonCustom = () => {
|
||||
const [stars, setStars] = React.useState(0);
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
|
||||
const username = "Soluce-Technologies"
|
||||
const username = "Portabase"
|
||||
const repo = "portabase"
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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 gap-x-4 flex-row w-full", className)}>
|
||||
<TablePaginationSize table={table} pageSizeOptions={pageSizeOptions} />
|
||||
<TablePaginationNavigation table={table} maxVisiblePages={maxVisiblePages} className="justify-end 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 className="h-full" value="users">
|
||||
<AdminUsersTable users={users}/>
|
||||
</TabsContent>
|
||||
<TabsContent className="h-full" value="organizations">
|
||||
<AdminOrganizationsTable organizations={organizations}/>
|
||||
</TabsContent>
|
||||
<TabsContent value="email">
|
||||
<SettingsEmailTab settings={settings}/>
|
||||
</TabsContent>
|
||||
<TabsContent value="storage">
|
||||
<SettingsStorageTab settings={settings}/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client"
|
||||
import {NotificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
||||
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
|
||||
import {NotifierCard} from "@/components/wrappers/dashboard/common/notifier/notifier-card/notifier-card";
|
||||
import {useState} from "react";
|
||||
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
|
||||
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||
import {NotifierAddEditModal} from "@/components/wrappers/dashboard/common/notifier/notifier-add-edit-modal";
|
||||
|
||||
type NotificationChannelsSectionProps = {
|
||||
notificationChannels: NotificationChannelWith[]
|
||||
organizations: OrganizationWithMembers[]
|
||||
}
|
||||
|
||||
export const NotificationChannelsSection = ({
|
||||
organizations,
|
||||
notificationChannels
|
||||
}: NotificationChannelsSectionProps) => {
|
||||
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
|
||||
const hasNotifiers = notificationChannels.length > 0;
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<NotifierAddEditModal open={isAddModalOpen} onOpenChangeAction={setIsAddModalOpen} adminView={false} trigger={false}/>
|
||||
{hasNotifiers ? (
|
||||
<div className="h-full">
|
||||
|
||||
<CardsWithPagination
|
||||
data={notificationChannels}
|
||||
cardItem={NotifierCard}
|
||||
cardsPerPage={8}
|
||||
numberOfColumns={2}
|
||||
adminView={true}
|
||||
organizations={organizations}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyStatePlaceholder
|
||||
text="No notification channels configured yet"
|
||||
onClick={() => {
|
||||
setIsAddModalOpen(true)
|
||||
}}
|
||||
className="h-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import {useRouter} from "next/navigation";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {Form, FormControl, FormField, FormItem, useZodForm} from "@/components/ui/form";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
||||
import {
|
||||
NotificationChannelsOrganizationSchema,
|
||||
NotificationChannelsOrganizationType
|
||||
} from "@/components/wrappers/dashboard/admin/notifications/channels/organization/notification-channels-organization.schema";
|
||||
import {MultiSelect} from "@/components/wrappers/common/multiselect/multi-select";
|
||||
import {
|
||||
updateNotificationChannelsOrganizationAction
|
||||
} from "@/components/wrappers/dashboard/admin/notifications/channels/organization/notification-channels-organization.action";
|
||||
import {toast} from "sonner";
|
||||
|
||||
|
||||
type NotifierChannelOrganisationFormProps = {
|
||||
organizations?: OrganizationWithMembers[];
|
||||
defaultValues?: NotificationChannelWith
|
||||
};
|
||||
|
||||
export const NotifierChannelOrganisationForm = ({
|
||||
organizations,
|
||||
defaultValues,
|
||||
}: NotifierChannelOrganisationFormProps) => {
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
|
||||
console.log("defaultValues", defaultValues);
|
||||
console.log("organizations", organizations);
|
||||
|
||||
const defaultOrganizationIds = defaultValues?.organizations?.map(organization => organization.organizationId) ?? []
|
||||
|
||||
|
||||
const form = useZodForm({
|
||||
schema: NotificationChannelsOrganizationSchema,
|
||||
// @ts-ignore
|
||||
defaultValues: {
|
||||
organizations: defaultOrganizationIds
|
||||
},
|
||||
});
|
||||
|
||||
const formatOrganizationsList = (organizations: OrganizationWithMembers[]) => {
|
||||
return organizations
|
||||
.map((organization) => ({
|
||||
value: organization.id,
|
||||
label: `${organization.name}`,
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
const mutationUpdateNotificationChannelOrganizations = useMutation({
|
||||
mutationFn: async (values: NotificationChannelsOrganizationType) => {
|
||||
|
||||
const payload = {
|
||||
data: values.organizations,
|
||||
notificationChannelId: defaultValues?.id ?? ""
|
||||
};
|
||||
|
||||
const result = await updateNotificationChannelsOrganizationAction(payload)
|
||||
const inner = result?.data;
|
||||
|
||||
if (inner?.success) {
|
||||
toast.success(inner.actionSuccess?.message);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(inner?.actionError?.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={async (values) => {
|
||||
await mutationUpdateNotificationChannelOrganizations.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`organizations`}
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<MultiSelect
|
||||
options={formatOrganizationsList(organizations ?? [])}
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value ?? []}
|
||||
placeholder="Select organization(s)"
|
||||
variant="inverted"
|
||||
animation={0}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<div className="flex gap-2 justify-end">
|
||||
<ButtonWithLoading isPending={mutationUpdateNotificationChannelOrganizations.isPending}>
|
||||
Save
|
||||
</ButtonWithLoading>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
"use server"
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
import {z} from "zod";
|
||||
import {ServerActionResult} from "@/types/action-type";
|
||||
import {db} from "@/db";
|
||||
import {and, eq, inArray} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
||||
|
||||
|
||||
export const updateNotificationChannelsOrganizationAction = userAction
|
||||
.schema(
|
||||
z.object({
|
||||
data: z.array(z.string()),
|
||||
notificationChannelId: z.string(),
|
||||
})
|
||||
)
|
||||
.action(async ({parsedInput, ctx}): Promise<ServerActionResult<null>> => {
|
||||
try {
|
||||
const organizationsIds = parsedInput.data;
|
||||
|
||||
const notificationChannel = await db.query.notificationChannel.findFirst({
|
||||
where: eq(drizzleDb.schemas.notificationChannel.id, parsedInput.notificationChannelId),
|
||||
with: {
|
||||
organizations: true,
|
||||
}
|
||||
}) as NotificationChannelWith;
|
||||
|
||||
|
||||
if (!notificationChannel) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Notification channel not found.",
|
||||
status: 404,
|
||||
cause: "not_found",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const existingItemIds = notificationChannel.organizations.map((organization) => organization.organizationId);
|
||||
|
||||
const organizationsToAdd = organizationsIds.filter((id) => !existingItemIds.includes(id));
|
||||
const organizationsToRemove = existingItemIds.filter((id) => !organizationsIds.includes(id));
|
||||
|
||||
if (organizationsToAdd.length > 0) {
|
||||
for (const organizationToAdd of organizationsToAdd) {
|
||||
await db.insert(drizzleDb.schemas.organizationNotificationChannel).values({
|
||||
organizationId: organizationToAdd,
|
||||
notificationChannelId: parsedInput.notificationChannelId
|
||||
});
|
||||
}
|
||||
}
|
||||
if (organizationsToRemove.length > 0) {
|
||||
await db.delete(drizzleDb.schemas.organizationNotificationChannel).where(and(inArray(drizzleDb.schemas.organizationNotificationChannel.organizationId, organizationsToRemove), eq(drizzleDb.schemas.organizationNotificationChannel.notificationChannelId, parsedInput.notificationChannelId))).execute();
|
||||
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: null,
|
||||
actionSuccess: {
|
||||
message: "Notification channel organizations has been successfully updated.",
|
||||
messageParams: {notificationChannelId: parsedInput.notificationChannelId},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error updating notification channel:", error);
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Failed to update notification channel.",
|
||||
status: 500,
|
||||
cause: "server_error",
|
||||
messageParams: {message: "Error updating the notification channel"},
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const NotificationChannelsOrganizationSchema = z.object({
|
||||
organizations: z.array(z.string())
|
||||
});
|
||||
|
||||
export type NotificationChannelsOrganizationType = z.infer<typeof NotificationChannelsOrganizationSchema>;
|
||||
@@ -0,0 +1,13 @@
|
||||
import {Mail, MessageSquare, Webhook} from "lucide-react";
|
||||
|
||||
export const getNotificationChannelIcon = (type: string) => {
|
||||
const Icon = notificationTypes.find((t) => t.value === type)?.icon
|
||||
return Icon ? <Icon className="h-4 w-4"/> : null
|
||||
}
|
||||
|
||||
|
||||
const notificationTypes = [
|
||||
{value: "smtp", label: "Email", icon: Mail},
|
||||
{value: "slack", label: "Slack", icon: MessageSquare},
|
||||
{value: "webhook", label: "Webhook", icon: Webhook},
|
||||
]
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import {ColumnDef} from "@tanstack/react-table";
|
||||
import {NotificationLogWithRelations} from "@/db/services/notification-log";
|
||||
import {getNotificationChannelIcon} from "@/components/wrappers/dashboard/admin/notifications/helpers";
|
||||
import {humanReadableDate} from "@/utils/date-formatting";
|
||||
import {CheckCircle2, XCircle} from "lucide-react";
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
import {NotificationLogModal} from "@/components/wrappers/dashboard/admin/notifications/logs/notification-log-modal";
|
||||
|
||||
|
||||
export function notificationLogsColumns(): ColumnDef<NotificationLogWithRelations>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: "success",
|
||||
header: "Status",
|
||||
cell: ({row}) => {
|
||||
const status = row.original.success ? "delivered" : "failed";
|
||||
return(
|
||||
<Badge variant="outline" className={`gap-1.5 ${getStatusColor(status)}`}>
|
||||
{getStatusIcon(row.original.success)}
|
||||
<span className="capitalize">{status}</span>
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "channel",
|
||||
header: "Channel",
|
||||
cell: ({row}) => {
|
||||
const channel = row.original.channel
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="flex h-10 w-10 items-center justify-center rounded-md bg-secondary border border-border">
|
||||
{getNotificationChannelIcon(channel?.provider ?? "")}
|
||||
</div>
|
||||
{channel?.name}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "sentAt",
|
||||
header: "Sent At",
|
||||
cell: ({row}) => {
|
||||
const sentAt = humanReadableDate(row.original.sentAt)
|
||||
return (
|
||||
<div>
|
||||
{sentAt}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "policy",
|
||||
header: "Event Kind",
|
||||
cell: ({ row }) => {
|
||||
const eventKind = row.original.policy?.event;
|
||||
|
||||
if (!eventKind) {
|
||||
return (
|
||||
<div className="text-muted-foreground italic">
|
||||
No event
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge>{eventKind}</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "details",
|
||||
header: "Details",
|
||||
cell: ({row}) => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<NotificationLogModal notificationLog={row.original} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
const getStatusIcon = (status: boolean) => {
|
||||
switch (status) {
|
||||
case true:
|
||||
return <CheckCircle2 className="h-4 w-4"/>
|
||||
case false:
|
||||
return <XCircle className="h-4 w-4"/>
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "delivered":
|
||||
return "bg-green-100 dark:bg-green-100/10"
|
||||
case "failed":
|
||||
return "bg-red-100 dark:bg-red-100/10"
|
||||
case "pending":
|
||||
return "bg-orange-100 dark:bg-orange-100/10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client"
|
||||
import {Eye} from "lucide-react";
|
||||
|
||||
import {useState} from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from "@/components/ui/dialog";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {NotificationLogWithRelations} from "@/db/services/notification-log";
|
||||
|
||||
type NotificationLogModalProps = {
|
||||
notificationLog: NotificationLogWithRelations;
|
||||
}
|
||||
|
||||
export const NotificationLogModal = ({notificationLog}: NotificationLogModalProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" onClick={() => setOpen(true)} className="relative">
|
||||
<Eye/>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Notification details</DialogTitle>
|
||||
<DialogDescription>
|
||||
Details of the notification sent
|
||||
</DialogDescription>
|
||||
<Separator className="mt-3 mb-3"/>
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
<div>
|
||||
<span className="font-semibold">Title:</span>{" "}
|
||||
<span>{notificationLog.content.title}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Message:</span>{" "}
|
||||
<span>{notificationLog.content.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notificationLog.payload && (
|
||||
<>
|
||||
<Separator className="mb-3" />
|
||||
<div className="text-sm font-mono bg-gray-100 dark:bg-gray-800 dark:text-gray-200 p-4 rounded-md overflow-auto">
|
||||
{JSON.stringify(notificationLog.payload, null, 2)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
import {DataTable} from "@/components/wrappers/common/table/data-table";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {
|
||||
notificationLogsColumns,
|
||||
} from "@/components/wrappers/dashboard/admin/notifications/logs/columns";
|
||||
import {NotificationLogWithRelations} from "@/db/services/notification-log";
|
||||
|
||||
|
||||
type NotificationsLogsListProps = {
|
||||
notificationLogs: NotificationLogWithRelations[]
|
||||
}
|
||||
|
||||
export const NotificationLogsList = (props: NotificationsLogsListProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
enableSelect={false}
|
||||
columns={notificationLogsColumns()}
|
||||
data={props.notificationLogs}
|
||||
enablePagination
|
||||
/>
|
||||
)
|
||||
}
|
||||