mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4447180e9e | ||
|
|
f111946592 | ||
|
|
475e404fae | ||
|
|
07ce5215d4 | ||
|
|
9f4895fd63 | ||
|
|
c18a9da0d3 | ||
|
|
8e3eb32631 | ||
|
|
f0598b3f59 | ||
|
|
011120fdf9 | ||
|
|
ff175e1edd | ||
|
|
3f56008c39 | ||
|
|
5df95bf7c1 | ||
|
|
23d52d0447 |
@@ -24,5 +24,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Fetch full history for gitleaks
|
fetch-depth: 0 # Fetch full history for gitleaks
|
||||||
- uses: gitleaks/gitleaks-action@v2
|
- uses: gitleaks/gitleaks-action@v2
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
|
||||||
with:
|
with:
|
||||||
config-path: .gitleaks.toml # Optional, if you have a custom config
|
config-path: .gitleaks.toml # Optional, if you have a custom config
|
||||||
+1
-1
@@ -26,7 +26,7 @@ Please take a moment to review this guide. It will help you understand how to co
|
|||||||
|
|
||||||
2. **Clone the repository**
|
2. **Clone the repository**
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/Soluce-Technologies/portabase
|
git clone https://github.com/Portabase/portabase
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Set up the development environment**
|
3. **Set up the development environment**
|
||||||
|
|||||||
@@ -21,15 +21,16 @@
|
|||||||
<p>
|
<p>
|
||||||
<strong>
|
<strong>
|
||||||
<a href="https://portabase.io">Documentation</a> •
|
<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="#installation">Installation</a> •
|
||||||
<a href="#contributing">Contributing</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/Portabase/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=enhancement&template=feature-request---.md">Request Feature</a>
|
||||||
</strong>
|
</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -51,7 +52,7 @@
|
|||||||
**Portabase** is a server dashboard tool designed to simplify the backup and restoration of your database instances. It
|
**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.
|
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
|
### 🔧 Built With
|
||||||
|
|
||||||
@@ -61,7 +62,6 @@ GitHub Repository: [Portabase](https://github.com/Soluce-Technologies/portabase)
|
|||||||
- [![BetterAuth][BetterAuth]][BetterAuth-url]
|
- [![BetterAuth][BetterAuth]][BetterAuth-url]
|
||||||
- [![Docker][Docker]][Docker-url]
|
- [![Docker][Docker]][Docker-url]
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Getting Started
|
## 🚀 Getting Started
|
||||||
@@ -122,13 +122,13 @@ docker compose up -d
|
|||||||
```
|
```
|
||||||
|
|
||||||
If you use reverse proxy like
|
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)
|
### Option 2: Locally (Development)
|
||||||
|
|
||||||
1. Clone the repository:
|
1. Clone the repository:
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/Soluce-Technologies/portabase
|
git clone https://github.com/Portabase/portabase
|
||||||
cd portabase
|
cd portabase
|
||||||
```
|
```
|
||||||
2. Start the development environment:
|
2. Start the development environment:
|
||||||
@@ -150,7 +150,7 @@ It currently supports:
|
|||||||
|
|
||||||
1. **Access the dashboard** – Open `http://localhost:8887` in your browser.
|
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.
|
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.
|
instructions.
|
||||||
4. **Create organizations and projects** – Link your databases to projects to enable backups and restores.
|
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.
|
5. **Configure backup policies** – Define schedules (hourly, daily, weekly, or monthly) and retention rules.
|
||||||
@@ -177,7 +177,7 @@ It currently supports:
|
|||||||
- [x] MySQL
|
- [x] MySQL
|
||||||
- [x] MariaDB
|
- [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
|
### 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
|
- Killian Larcher - killian.larcher@soluce-technologies.com
|
||||||
- Charles Gauthereau - charles.gauthereau@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/
|
[Drizzle-url]: https://orm.drizzle.team/
|
||||||
|
|
||||||
[ShadcnUI-url]: https://ui.shadcn.com/
|
[ShadcnUI-url]: https://ui.shadcn.com/
|
||||||
|
|
||||||
[Docker-url]: https://www.docker.com/
|
[Docker-url]: https://www.docker.com/
|
||||||
|
|
||||||
|
|||||||
@@ -26,16 +26,14 @@ export default async function SignInPage() {
|
|||||||
<CardContent className="space-y-4">
|
<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">
|
<div className="relative my-4 flex items-center justify-center overflow-hidden">
|
||||||
<Separator/>
|
<Separator/>
|
||||||
<div className="px-2 text-center text-sm">OR</div>
|
<div className="px-2 text-center text-sm">OR</div>
|
||||||
<Separator/>
|
<Separator/>
|
||||||
</div>
|
</div>
|
||||||
|
<SocialAuthButtons providers={SUPPORTED_PROVIDERS}/>
|
||||||
<SocialAuthButtons />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -50,7 +48,5 @@ export default async function SignInPage() {
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {PageParams} from "@/types/next";
|
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 {db} from "@/db";
|
||||||
import {DataTable} from "@/components/wrappers/common/table/data-table";
|
import {desc, isNull} from "drizzle-orm";
|
||||||
import {usersColumnsAdmin} from "@/components/wrappers/dashboard/admin/users/columns-users";
|
import {AdminUserList} from "@/components/wrappers/dashboard/admin/users/admin-user-list";
|
||||||
import {isNull} from "drizzle-orm";
|
import {AdminUserAddModal} from "@/components/wrappers/dashboard/admin/users/admin-user-add-modal";
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{}>) {
|
export default async function RoutePage(props: PageParams<{}>) {
|
||||||
|
|
||||||
@@ -11,7 +11,14 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
where: (fields) => isNull(fields.deletedAt),
|
where: (fields) => isNull(fields.deletedAt),
|
||||||
with: {
|
with: {
|
||||||
accounts: true
|
accounts: true
|
||||||
}
|
},
|
||||||
|
orderBy: (fields) => desc(fields.createdAt),
|
||||||
|
|
||||||
|
});
|
||||||
|
const organizations = await db.query.organization.findMany({
|
||||||
|
with: {
|
||||||
|
members: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -19,14 +26,16 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
<PageHeader className="flex flex-col">
|
<PageHeader className="flex flex-col">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<PageTitle className="mb-3">Active users</PageTitle>
|
<PageTitle className="mb-3">Active users</PageTitle>
|
||||||
|
<PageActions>
|
||||||
|
<AdminUserAddModal organizations={organizations}/>
|
||||||
|
</PageActions>
|
||||||
</div>
|
</div>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<PageContent className="flex flex-col gap-5">
|
<PageContent className="flex flex-col gap-5">
|
||||||
<DataTable
|
<AdminUserList users={users}/>
|
||||||
enableSelect={false}
|
|
||||||
columns={usersColumnsAdmin}
|
|
||||||
data={users}/>
|
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -78,9 +78,6 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
(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 (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
@@ -125,31 +122,12 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row gap-4">
|
<div className="flex flex-col md:flex-row gap-4">
|
||||||
<Card className="w-full">
|
<EvolutionLineChart
|
||||||
<CardHeader>
|
data={sortedBackupsEvolution}
|
||||||
<CardTitle>Evolution of the number of backups</CardTitle>
|
/>
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{sortedBackupsEvolution.length > 0 ? (
|
|
||||||
<EvolutionLineChart data={sortedBackupsEvolution}/>
|
|
||||||
) : (
|
|
||||||
<Placeholder text="No backup data available"/>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="w-full">
|
<PercentageLineChart
|
||||||
<CardHeader>
|
data={backupsRate}/>
|
||||||
<CardTitle>Success rate of backups</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{backupsRate.length > 0 ? (
|
|
||||||
<PercentageLineChart data={backupsRate}/>
|
|
||||||
) : (
|
|
||||||
<Placeholder text="No backup rate data available"/>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</Page>
|
</Page>
|
||||||
|
|||||||
+13
-13
@@ -1,9 +1,9 @@
|
|||||||
import {Chrome, KeyRound, LucideIcon} from "lucide-react";
|
|
||||||
|
|
||||||
|
|
||||||
export interface AuthProviderConfig {
|
export interface AuthProviderConfig {
|
||||||
id: "google" | "github" | "credential";
|
id: "google" | "github" | "credential";
|
||||||
icon: LucideIcon;
|
isActive: boolean;
|
||||||
|
icon: string;
|
||||||
isManual?: boolean;
|
isManual?: boolean;
|
||||||
credentials?: {
|
credentials?: {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
@@ -80,27 +80,27 @@ export const PORTABASE_DEFAULT_SETTINGS = {
|
|||||||
export const SUPPORTED_PROVIDERS: AuthProviderConfig[] = [
|
export const SUPPORTED_PROVIDERS: AuthProviderConfig[] = [
|
||||||
{
|
{
|
||||||
id: "google",
|
id: "google",
|
||||||
icon: Chrome,
|
icon: "hugeicons:chrome",
|
||||||
|
isActive: Boolean(process.env.AUTH_GOOGLE_METHOD),
|
||||||
// isManual: true,
|
// isManual: true,
|
||||||
credentials: {
|
credentials: {
|
||||||
clientId: process.env.GOOGLE_CLIENT_ID || "",
|
clientId: process.env.AUTH_GOOGLE_ID || "",
|
||||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
|
clientSecret: process.env.AUTH_GOOGLE_SECRET || "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
/*
|
|
||||||
{
|
{
|
||||||
id: "github",
|
id: "github",
|
||||||
name: "GitHub",
|
icon: "iconoir:github",
|
||||||
icon: Github,
|
isActive: Boolean(process.env.AUTH_GITHUB_METHOD),
|
||||||
description: "Connexion via GitHub",
|
|
||||||
credentials: {
|
credentials: {
|
||||||
clientId: process.env.GITHUB_CLIENT_ID || "",
|
clientId: process.env.AUTH_GITHUB_ID || "",
|
||||||
clientSecret: process.env.GITHUB_CLIENT_SECRET || "",
|
clientSecret: process.env.AUTH_GITHUB_SECRET || "",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},*/
|
|
||||||
{
|
{
|
||||||
id: "credential",
|
id: "credential",
|
||||||
icon: KeyRound,
|
isActive: true,
|
||||||
|
icon: "proicons:key",
|
||||||
isManual: true,
|
isManual: true,
|
||||||
credentials: {
|
credentials: {
|
||||||
clientId: "",
|
clientId: "",
|
||||||
|
|||||||
@@ -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>
|
<Tailwind>
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
|
<Body className="mx-auto my-auto bg-white px-2 font-sans">
|
||||||
{preview ? <Preview>{preview}</Preview> : <Preview>Please check your mails</Preview>}
|
{preview ? <Preview>{preview}</Preview> : <Preview>Please check your mails</Preview>}
|
||||||
<Body className="bg-gray-100 py-4" style={{ fontFamily: "Arial, sans-serif" }}>
|
<Container className="mx-auto my-[40px] max-w-[465px] rounded border border-[#eaeaea] border-solid p-[20px]">
|
||||||
<Container className="bg-white border border-gray-200 p-12">
|
<Section className="mt-[32px]">
|
||||||
<Img src={`${baseUrl}/images/logo-dark.png`} width="200" height="auto" alt="Logo" />
|
<Img
|
||||||
<Section>{children}</Section>
|
src={`${baseUrl}/images/logo.png`}
|
||||||
|
width="50"
|
||||||
|
height="auto"
|
||||||
|
alt="Logo"
|
||||||
|
className="mx-auto my-0"
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
{children}
|
||||||
</Container>
|
</Container>
|
||||||
</Body>
|
</Body>
|
||||||
</Html>
|
</Html>
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { authClient } from "@/lib/auth/auth-client";
|
import { authClient } from "@/lib/auth/auth-client";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
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 [isLoading, setIsLoading] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleSocialSignIn = async (providerId: string) => {
|
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;
|
if (socialProviders.length === 0) return null;
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 w-full">
|
<div className="flex flex-col gap-2 w-full">
|
||||||
{socialProviders.map((provider) => (
|
{socialProviders.map((provider) => (
|
||||||
<Button key={provider.id} variant="outline" className="w-full gap-2" onClick={() => handleSocialSignIn(provider.id)} disabled={!!isLoading}>
|
<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>
|
<span>{PROVIDERS_TEXT[provider.id].title}</span>
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -43,13 +43,17 @@ export function ComboBox<T = string>(props: ComboBoxProps<T>) {
|
|||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
{sideBar ? (
|
{sideBar ? (
|
||||||
<SidebarMenuButton>
|
<SidebarMenuButton>
|
||||||
|
<label className="max-w-[170px] truncate">
|
||||||
{value ? choices.find((c) => c.value === value)?.label : "Select choice..."}
|
{value ? choices.find((c) => c.value === value)?.label : "Select choice..."}
|
||||||
|
</label>
|
||||||
<ChevronDown className="ml-auto"/>
|
<ChevronDown className="ml-auto"/>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="outline" role="combobox" aria-expanded={open}
|
<Button variant="outline" role="combobox" aria-expanded={open}
|
||||||
className="w-full justify-between">
|
className="w-full justify-between">
|
||||||
|
<label className="max-w-[170px] truncate">
|
||||||
{value ? choices.find((c) => c.value === value)?.label : "Select choice..."}
|
{value ? choices.find((c) => c.value === value)?.label : "Select choice..."}
|
||||||
|
</label>
|
||||||
<ChevronDown className="opacity-50"/>
|
<ChevronDown className="opacity-50"/>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -80,7 +84,9 @@ export function ComboBox<T = string>(props: ComboBoxProps<T>) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<label className="max-w-[170px] truncate">
|
||||||
{choice.label}
|
{choice.label}
|
||||||
|
</label>
|
||||||
<Check
|
<Check
|
||||||
className={cn("ml-auto", value === choice.value ? "opacity-100" : "opacity-0")}/>
|
className={cn("ml-auto", value === choice.value ? "opacity-100" : "opacity-0")}/>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export const GitHubStarsButtonCustom = () => {
|
|||||||
const [stars, setStars] = React.useState(0);
|
const [stars, setStars] = React.useState(0);
|
||||||
const [isLoading, setIsLoading] = React.useState(true);
|
const [isLoading, setIsLoading] = React.useState(true);
|
||||||
|
|
||||||
const username = "Soluce-Technologies"
|
const username = "Portabase"
|
||||||
const repo = "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>;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {currentUser} from "@/lib/auth/current-user";
|
import {currentUser} from "@/lib/auth/current-user";
|
||||||
import {getAccounts, getSession, getSessions} from "@/lib/auth/auth";
|
import {getAccounts, getSession, getSessions} from "@/lib/auth/auth";
|
||||||
import {LoggedInButtonClient} from "./logged-in-button";
|
import {LoggedInButtonClient} from "./logged-in-button";
|
||||||
|
import {SUPPORTED_PROVIDERS} from "../../../../../../portabase.config";
|
||||||
|
|
||||||
export const LoggedInButton = async () => {
|
export const LoggedInButton = async () => {
|
||||||
const user = await currentUser();
|
const user = await currentUser();
|
||||||
@@ -18,6 +19,7 @@ export const LoggedInButton = async () => {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
currentSession={currentSession.session}
|
currentSession={currentSession.session}
|
||||||
accounts={accounts}
|
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";
|
"use client";
|
||||||
|
|
||||||
import {ChevronUp} from "lucide-react";
|
import {ChevronsUpDown, ChevronUp} from "lucide-react";
|
||||||
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
|
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
|
||||||
import {SidebarMenuButton} from "@/components/ui/sidebar";
|
import {SidebarMenuButton} from "@/components/ui/sidebar";
|
||||||
import {LoggedInDropdown} from "./logged-in-dropdown";
|
import {LoggedInDropdown} from "./logged-in-dropdown";
|
||||||
import {Account, Session, User} from "better-auth";
|
import {Account, Session, User} from "better-auth";
|
||||||
|
import {AuthProviderConfig} from "../../../../../../portabase.config";
|
||||||
|
|
||||||
type LoggedInButtonClientProps = {
|
type LoggedInButtonClientProps = {
|
||||||
user: User;
|
user: User,
|
||||||
sessions: Session[];
|
sessions: Session[],
|
||||||
currentSession: Session;
|
currentSession: Session,
|
||||||
accounts: Account[];
|
accounts: Account[],
|
||||||
|
providers: AuthProviderConfig[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LoggedInButtonClient = ({user, sessions, currentSession, accounts}: LoggedInButtonClientProps) => {
|
export const LoggedInButtonClient = ({
|
||||||
|
user,
|
||||||
|
sessions,
|
||||||
|
currentSession,
|
||||||
|
accounts,
|
||||||
|
providers
|
||||||
|
}: LoggedInButtonClientProps) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LoggedInDropdown
|
<LoggedInDropdown
|
||||||
@@ -68,15 +33,25 @@ export const LoggedInButtonClient = ({user, sessions, currentSession, accounts}:
|
|||||||
currentSession={currentSession}
|
currentSession={currentSession}
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
|
providers={providers}
|
||||||
>
|
>
|
||||||
<SidebarMenuButton type="button">
|
<SidebarMenuButton type="button" className="h-auto justify-between py-2" >
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<Avatar className="size-6">
|
<Avatar className="size-6">
|
||||||
<AvatarFallback>{user.name[0].toUpperCase()}</AvatarFallback>
|
<AvatarFallback>{user.name[0].toUpperCase()}</AvatarFallback>
|
||||||
{user.image && <AvatarImage src={user.image}/>}
|
{user.image && <AvatarImage src={user.image}/>}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
<span className="first-letter:capitalize">{user.name}</span>
|
<span className="text-sm font-medium first-letter:capitalize max-w-[170px] truncate">{user.name}</span>
|
||||||
<ChevronUp className="ml-auto"/>
|
<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>
|
</SidebarMenuButton>
|
||||||
</LoggedInDropdown>
|
</LoggedInDropdown>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
|
|||||||
import { signOut } from "@/lib/auth/auth-client";
|
import { signOut } from "@/lib/auth/auth-client";
|
||||||
import {ProfileModal} from "@/components/wrappers/dashboard/common/profile/profile-modal";
|
import {ProfileModal} from "@/components/wrappers/dashboard/common/profile/profile-modal";
|
||||||
import {Account, Session, User} from "@/db/schema/02_user";
|
import {Account, Session, User} from "@/db/schema/02_user";
|
||||||
|
import {AuthProviderConfig} from "../../../../../../portabase.config";
|
||||||
|
|
||||||
export type LoggedInDropdownProps = PropsWithChildren<{
|
export type LoggedInDropdownProps = PropsWithChildren<{
|
||||||
user: User;
|
user: User;
|
||||||
@@ -14,9 +15,11 @@ export type LoggedInDropdownProps = PropsWithChildren<{
|
|||||||
currentSession: Session;
|
currentSession: Session;
|
||||||
accounts: Account[];
|
accounts: Account[];
|
||||||
children: ReactNode;
|
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 router = useRouter();
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
||||||
@@ -29,6 +32,7 @@ export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, chi
|
|||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
open={isModalOpen}
|
open={isModalOpen}
|
||||||
onOpenChange={setIsModalOpen}
|
onOpenChange={setIsModalOpen}
|
||||||
|
providers={providers}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {ProfileAccount} from "@/components/wrappers/dashboard/profile/profile-ac
|
|||||||
import {ProfileAppearance} from "@/components/wrappers/dashboard/profile/profile-apperance";
|
import {ProfileAppearance} from "@/components/wrappers/dashboard/profile/profile-apperance";
|
||||||
import {ProfileSecurity} from "@/components/wrappers/dashboard/profile/profile-security";
|
import {ProfileSecurity} from "@/components/wrappers/dashboard/profile/profile-security";
|
||||||
import {ProfileGeneral} from "@/components/wrappers/dashboard/profile/profile-general";
|
import {ProfileGeneral} from "@/components/wrappers/dashboard/profile/profile-general";
|
||||||
|
import {AuthProviderConfig} from "../../../../../../portabase.config";
|
||||||
|
|
||||||
type ProfileModalProps = {
|
type ProfileModalProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -18,9 +19,11 @@ type ProfileModalProps = {
|
|||||||
currentSession: Session;
|
currentSession: Session;
|
||||||
accounts: Account[];
|
accounts: Account[];
|
||||||
onOpenChange: (open: boolean) => void;
|
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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<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">
|
<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>
|
||||||
|
|
||||||
<TabsContent value="providers" className="mt-0 h-full p-6 lg:p-10 outline-none focus-visible:ring-0">
|
<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>
|
||||||
|
|
||||||
<TabsContent value="account" className="mt-0 h-full p-6 lg:p-10 outline-none focus-visible:ring-0">
|
<TabsContent value="account" className="mt-0 h-full p-6 lg:p-10 outline-none focus-visible:ring-0">
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ export function AppSidebar() {
|
|||||||
<SidebarMenu className="mb-2">
|
<SidebarMenu className="mb-2">
|
||||||
<SidebarMenuItem className="p-2">
|
<SidebarMenuItem className="p-2">
|
||||||
<LoggedInButton/>
|
<LoggedInButton/>
|
||||||
|
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
<SideBarFooterCredit/>
|
<SideBarFooterCredit/>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
import {User as BetterAuthUser} from "better-auth";
|
import {User as BetterAuthUser} from "better-auth";
|
||||||
import {User} from "@/db/schema/02_user";
|
import {User} from "@/db/schema/02_user";
|
||||||
|
import {authClient} from "@/lib/auth/auth-client";
|
||||||
|
|
||||||
export type organizationFormProps = {
|
export type organizationFormProps = {
|
||||||
defaultValues?: OrganizationWithMembers;
|
defaultValues?: OrganizationWithMembers;
|
||||||
@@ -36,6 +37,8 @@ export type organizationFormProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const OrganizationForm = (props: organizationFormProps) => {
|
export const OrganizationForm = (props: organizationFormProps) => {
|
||||||
|
const {data: activeOrganization, refetch: refetchActiveOrga} = authClient.useActiveOrganization();
|
||||||
|
const {data: organizations, refetch} = authClient.useListOrganizations();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isCreate = !Boolean(props.defaultValues);
|
const isCreate = !Boolean(props.defaultValues);
|
||||||
@@ -75,6 +78,8 @@ export const OrganizationForm = (props: organizationFormProps) => {
|
|||||||
onSuccess: async (result) => {
|
onSuccess: async (result) => {
|
||||||
if (result?.data?.success) {
|
if (result?.data?.success) {
|
||||||
toast.success(result.data.actionSuccess?.message || "Organization updated successfully.");
|
toast.success(result.data.actionSuccess?.message || "Organization updated successfully.");
|
||||||
|
refetch()
|
||||||
|
refetchActiveOrga()
|
||||||
router.push("/dashboard/settings");
|
router.push("/dashboard/settings");
|
||||||
} else {
|
} else {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { UploadIcon } from "lucide-react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { uploadImageAction } from "@/features/upload/public/upload.action";
|
import { uploadImageAction } from "@/features/upload/public/upload.action";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
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 { useRouter } from "next/navigation";
|
||||||
import { User } from "@/db/schema/02_user";
|
import { User } from "@/db/schema/02_user";
|
||||||
import React, {ChangeEvent} from "react";
|
import React, {ChangeEvent} from "react";
|
||||||
|
|||||||
@@ -12,13 +12,16 @@ import {useRouter} from "next/navigation";
|
|||||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
|
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
|
||||||
import {Alert, AlertDescription} from "@/components/ui/alert";
|
import {Alert, AlertDescription} from "@/components/ui/alert";
|
||||||
import {SetPasswordProfileProviderModal} from "./modal/set-password-modal";
|
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 {
|
interface ProfileProviderProps {
|
||||||
accounts: Account[];
|
accounts: Account[];
|
||||||
|
providers: AuthProviderConfig[]
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProfileProviders({accounts}: ProfileProviderProps) {
|
export function ProfileProviders({accounts, providers}: ProfileProviderProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const totalConnected = accounts.length;
|
const totalConnected = accounts.length;
|
||||||
const [loadingProvider, setLoadingProvider] = useState<string | null>(null);
|
const [loadingProvider, setLoadingProvider] = useState<string | null>(null);
|
||||||
@@ -70,7 +73,7 @@ export function ProfileProviders({accounts}: ProfileProviderProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{SUPPORTED_PROVIDERS.map((provider) => {
|
{providers.map((provider) => {
|
||||||
const linkedAccount = accounts.find((acc) => acc.providerId === provider.id);
|
const linkedAccount = accounts.find((acc) => acc.providerId === provider.id);
|
||||||
const isConnected = !!linkedAccount;
|
const isConnected = !!linkedAccount;
|
||||||
// const canUnlink = totalConnected > 1 || (totalConnected === 1 && !provider.isManual);
|
// const canUnlink = totalConnected > 1 || (totalConnected === 1 && !provider.isManual);
|
||||||
@@ -83,7 +86,7 @@ export function ProfileProviders({accounts}: ProfileProviderProps) {
|
|||||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted/30 transition-colors">
|
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="flex items-center gap-4">
|
||||||
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center">
|
<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>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<div className="font-medium flex items-center gap-2">
|
<div className="font-medium flex items-center gap-2">
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,41 +1,39 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
|
import {ChartConfig, ChartTooltip} from "@/components/ui/chart";
|
||||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
import {CartesianGrid, TooltipProps, XAxis, YAxis} from "recharts";
|
||||||
import { humanReadableDate } from "@/utils/date-formatting";
|
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 = {
|
type Data = {
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type evolutionLineChartProps = {
|
type Payload = {
|
||||||
data: Data[];
|
date: string;
|
||||||
|
count: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function EvolutionLineChart(props: evolutionLineChartProps) {
|
type LineChartDatum = {
|
||||||
|
date: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EvolutionLineChartProps = {
|
||||||
|
data: Data[];
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EvolutionLineChart(props: EvolutionLineChartProps) {
|
||||||
const {data} = props;
|
const {data} = props;
|
||||||
|
|
||||||
// const cumulativeData = data.reduce(
|
// const fakeData = generateFakeEvolutionData(14, 2, 10)
|
||||||
// (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
|
const dailyData = data
|
||||||
.reduce((acc, backup) => {
|
.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);
|
const existing = acc.find(item => item.date === date);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -51,23 +49,19 @@ export function EvolutionLineChart(props: evolutionLineChartProps) {
|
|||||||
const chartConfig = {
|
const chartConfig = {
|
||||||
date: {
|
date: {
|
||||||
label: "Date",
|
label: "Date",
|
||||||
color: "#2563eb",
|
|
||||||
},
|
},
|
||||||
count: {
|
count: {
|
||||||
label: "Number of backups ",
|
label: "Number of backups ",
|
||||||
color: "#60a5fa",
|
|
||||||
},
|
},
|
||||||
} satisfies ChartConfig;
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartContainer config={chartConfig}>
|
|
||||||
<LineChart
|
<LineChartCustom<LineChartDatum>
|
||||||
accessibilityLayer
|
config={chartConfig}
|
||||||
data={dailyData}
|
data={dailyData}
|
||||||
margin={{
|
title="Evolution of the number of backups"
|
||||||
left: 12,
|
dataKey="count"
|
||||||
right: 12,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<CartesianGrid vertical={false}/>
|
<CartesianGrid vertical={false}/>
|
||||||
<XAxis
|
<XAxis
|
||||||
@@ -75,16 +69,46 @@ export function EvolutionLineChart(props: evolutionLineChartProps) {
|
|||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickMargin={8}
|
tickMargin={8}
|
||||||
tickFormatter={(value) => {
|
tickFormatter={(value) =>
|
||||||
return humanReadableDate(new Date(value)).split(" ")[0]
|
formatDayOnly(new Date(value))
|
||||||
}}
|
}
|
||||||
/>
|
/>
|
||||||
<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} />*/}
|
<YAxis tickLine={false}/>
|
||||||
</LineChart>
|
|
||||||
</ChartContainer>
|
<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";
|
"use client";
|
||||||
|
|
||||||
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
|
import {ChartConfig, ChartTooltip} from "@/components/ui/chart";
|
||||||
import {EStatusSchema} from "@/db/schema/types";
|
import {EStatusSchema} from "@/db/schema/types";
|
||||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
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 = {
|
type Data = {
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
@@ -10,6 +12,11 @@ type Data = {
|
|||||||
_count: number;
|
_count: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type Payload = {
|
||||||
|
date: string
|
||||||
|
successRate: number
|
||||||
|
}
|
||||||
|
|
||||||
export type percentageLineChartProps = {
|
export type percentageLineChartProps = {
|
||||||
data: Data[];
|
data: Data[];
|
||||||
};
|
};
|
||||||
@@ -17,20 +24,10 @@ export type percentageLineChartProps = {
|
|||||||
export function PercentageLineChart(props: percentageLineChartProps) {
|
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(
|
const dailyStats = data.reduce(
|
||||||
(acc, backup) => {
|
(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;
|
const status = backup.status;
|
||||||
|
|
||||||
if (!acc[date]) {
|
if (!acc[date]) {
|
||||||
@@ -45,34 +42,81 @@ export function PercentageLineChart(props: percentageLineChartProps) {
|
|||||||
{} as Record<string, { success: number; failed: number; total: number }>
|
{} as Record<string, { success: number; failed: number; total: number }>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Format data for the chart
|
|
||||||
const formattedData = Object.entries(dailyStats).map(([date, stats]) => ({
|
const formattedData = Object.entries(dailyStats).map(([date, stats]) => ({
|
||||||
date,
|
date,
|
||||||
successRate: (stats.success / stats.total) * 100,
|
successRate: (stats.success / stats.total) * 100,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
date: {
|
||||||
|
label: "Date",
|
||||||
|
},
|
||||||
|
successRate: {
|
||||||
|
label: "Success Rate",
|
||||||
|
},
|
||||||
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartContainer config={chartConfig}>
|
|
||||||
<LineChart accessibilityLayer data={formattedData}>
|
<LineChartCustom<Payload>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
title="Success rate of backups"
|
||||||
<XAxis dataKey="date" />
|
config={chartConfig}
|
||||||
<YAxis domain={[0, 100]} tickFormatter={(tick) => `${tick}%`} />
|
data={formattedData}
|
||||||
<ChartTooltip
|
dataKey="successRate"
|
||||||
content={<ChartTooltipContent />}
|
margin={{
|
||||||
cursor={false}
|
left: -15,
|
||||||
defaultIndex={1}
|
right: 12,
|
||||||
formatter={(value, name) => (
|
}}
|
||||||
<div className="flex min-w-[130px] items-center text-xs text-muted-foreground">
|
>
|
||||||
{chartConfig[name as keyof typeof chartConfig]?.label || name}
|
{/*<CartesianGrid strokeDasharray="3 3" />*/}
|
||||||
<div className="ml-auto flex items-baseline gap-0.5 font-mono font-medium tabular-nums text-foreground">
|
<CartesianGrid vertical={false}/>
|
||||||
{value}
|
<XAxis
|
||||||
<span className="font-normal text-muted-foreground">%</span>
|
dataKey="date"
|
||||||
</div>
|
tickLine={false}
|
||||||
</div>
|
axisLine={false}
|
||||||
)}
|
tickMargin={8}
|
||||||
|
tickFormatter={(value) =>
|
||||||
|
formatDayOnly(new Date(value))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Line type="step" dataKey="successRate" stroke="#8884d8" strokeWidth={2} />
|
<YAxis
|
||||||
</LineChart>
|
tickLine={false}
|
||||||
</ChartContainer>
|
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>
|
||||||
|
);
|
||||||
@@ -126,6 +126,10 @@ export const projectRelations = relations(project, ({one}) => ({
|
|||||||
export const userSchema = createSelectSchema(user);
|
export const userSchema = createSelectSchema(user);
|
||||||
export type User = z.infer<typeof userSchema>;
|
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 const sessionSchema = createSelectSchema(session);
|
||||||
export type Session = z.infer<typeof sessionSchema>;
|
export type Session = z.infer<typeof sessionSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import {count, eq} from "drizzle-orm";
|
|||||||
import {MemberWithUser, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
|
import {MemberWithUser, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
|
||||||
import {sendEmail} from "@/lib/email/email-helper";
|
import {sendEmail} from "@/lib/email/email-helper";
|
||||||
import {render} from "@react-email/render";
|
import {render} from "@react-email/render";
|
||||||
import {SUPPORTED_PROVIDERS} from "../../../portabase.config";
|
import {AuthProviderConfig, SUPPORTED_PROVIDERS} from "../../../portabase.config";
|
||||||
import {withUpdatedAt} from "@/db/utils";
|
import {withUpdatedAt} from "@/db/utils";
|
||||||
import EmailVerification from "@/components/emails/auth/email-verification";
|
import EmailVerification from "@/components/emails/auth/email-verification";
|
||||||
import EmailForgotPassword from "@/components/emails/auth/email-forgot-password";
|
import EmailForgotPassword from "@/components/emails/auth/email-forgot-password";
|
||||||
@@ -79,7 +79,8 @@ export const auth = betterAuth({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
socialProviders: SUPPORTED_PROVIDERS.reduce((acc: any, provider: any) => {
|
socialProviders: SUPPORTED_PROVIDERS.reduce((acc: any, provider: AuthProviderConfig) => {
|
||||||
|
if (!provider.isActive) return acc;
|
||||||
if (provider.id === "credential") return acc;
|
if (provider.id === "credential") return acc;
|
||||||
if (provider.id === "google") {
|
if (provider.id === "google") {
|
||||||
acc.google = {
|
acc.google = {
|
||||||
@@ -87,6 +88,12 @@ export const auth = betterAuth({
|
|||||||
clientSecret: env.AUTH_GOOGLE_SECRET! as string,
|
clientSecret: env.AUTH_GOOGLE_SECRET! as string,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (provider.id === "github") {
|
||||||
|
acc.github = {
|
||||||
|
clientId: provider.credentials?.clientId,
|
||||||
|
clientSecret: provider.credentials?.clientSecret,
|
||||||
|
};
|
||||||
|
}
|
||||||
return acc;
|
return acc;
|
||||||
}, {}),
|
}, {}),
|
||||||
account: {
|
account: {
|
||||||
|
|||||||
+1
-1
@@ -4,8 +4,8 @@ export type SignUpUser = {
|
|||||||
email: string
|
email: string
|
||||||
password: string
|
password: string
|
||||||
callbackURL?: string
|
callbackURL?: string
|
||||||
|
role?: string
|
||||||
theme: string
|
theme: string
|
||||||
isDefaultPassword: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -38,3 +38,12 @@ export function formatDateLastContact(lastContact: string | number | Date | null
|
|||||||
: "Never connected.";
|
: "Never connected.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatDayOnly(date: Date) {
|
||||||
|
return new Intl.DateTimeFormat(LOCALE, {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
timeZone: TIMEZONE,
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export function extractNameFromEmail(email: string): string {
|
||||||
|
const localPart = email.split("@")[0];
|
||||||
|
const nameParts = localPart
|
||||||
|
.replace(/[_\.\-]/g, " ") // Replace underscores, dots, and hyphens with spaces
|
||||||
|
.split(" ") // Split into parts
|
||||||
|
.filter(Boolean); // Remove empty strings
|
||||||
|
|
||||||
|
return nameParts
|
||||||
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1)) // Capitalize each part
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export function generateValidPassword(length = 12) {
|
||||||
|
const upper = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||||
|
const lower = 'abcdefghijklmnopqrstuvwxyz';
|
||||||
|
const numbers = '0123456789';
|
||||||
|
const special = '#?!@$%^&*-';
|
||||||
|
const all = upper + lower + numbers + special;
|
||||||
|
|
||||||
|
const getRandomChar = (set: string) => set[Math.floor(Math.random() * set.length)];
|
||||||
|
|
||||||
|
const passwordChars = [
|
||||||
|
getRandomChar(upper),
|
||||||
|
getRandomChar(lower),
|
||||||
|
getRandomChar(numbers),
|
||||||
|
getRandomChar(special),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let i = passwordChars.length; i < length; i++) {
|
||||||
|
passwordChars.push(getRandomChar(all));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = passwordChars.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[passwordChars[i], passwordChars[j]] = [passwordChars[j], passwordChars[i]];
|
||||||
|
}
|
||||||
|
|
||||||
|
return passwordChars.join('');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user