Compare commits

..
13 Commits
55 changed files with 1483 additions and 488 deletions
+3
View File
@@ -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
View File
@@ -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**
+13 -12
View File
@@ -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>
![portabase-ezgif com-video-to-gif-converter](https://github.com/user-attachments/assets/e3929e58-fefd-44cc-ab26-6f8e1a860c43) ![portabase-dashboard](https://github.com/user-attachments/assets/f5ec7204-79c0-43ac-ba17-f2ed682c39d6)
</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
[![Contributors](https://contrib.rocks/image?repo=Soluce-Technologies/portabase)](https://github.com/Soluce-Technologies/portabase/graphs/contributors) [![Contributors](https://contrib.rocks/image?repo=Portabase/portabase)](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/
+2 -6
View File
@@ -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
View File
@@ -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;
+12 -4
View File
@@ -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>
);
+4
View File
@@ -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>;
+34
View File
@@ -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
}
+9 -2
View File
@@ -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
View File
@@ -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
} }
+9
View File
@@ -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);
}
+11
View File
@@ -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(" ");
}
+27
View File
@@ -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('');
}