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