This commit is contained in:
Théo LAGACHE
2026-02-23 12:04:48 +01:00
parent 5df6dd169a
commit 496be692e7
17 changed files with 4196 additions and 1346 deletions
@@ -1,61 +1,71 @@
import {PageParams} from "@/types/next"; import { PageParams } from "@/types/next";
import {Page, PageContent, PageDescription, PageTitle} from "@/features/layout/page"; import {
import {db} from "@/db"; Page,
PageContent,
PageDescription,
PageTitle,
} from "@/features/layout/page";
import { db } from "@/db";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {eq} from "drizzle-orm"; import { eq } from "drizzle-orm";
import {notFound} from "next/navigation"; import { notFound } from "next/navigation";
import {ButtonDeleteAgent} from "@/components/wrappers/dashboard/agent/button-delete-agent/button-delete-agent"; import { ButtonDeleteAgent } from "@/components/wrappers/dashboard/agent/button-delete-agent/button-delete-agent";
import {capitalizeFirstLetter} from "@/utils/text"; import { capitalizeFirstLetter } from "@/utils/text";
import {generateEdgeKey} from "@/utils/edge_key"; import { generateEdgeKey } from "@/utils/edge_key";
import {getServerUrl} from "@/utils/get-server-url"; import { getServerUrl } from "@/utils/get-server-url";
import {AgentContentPage} from "@/components/wrappers/dashboard/agent/agent-content"; import { AgentContentPage } from "@/components/wrappers/dashboard/agent/agent-content";
import {AgentDialog} from "@/features/agents/components/agent.dialog"; import { AgentDialog } from "@/features/agents/components/agent.dialog";
import {AgentType} from "@/features/agents/agents.schema"; import { AgentType } from "@/features/agents/agents.schema";
export default async function RoutePage(props: PageParams<{ agentId: string }>) { export default async function RoutePage(
props: PageParams<{ agentId: string }>,
) {
const { agentId } = await props.params;
const {agentId} = await props.params const agent = await db.query.agent.findFirst({
where: eq(drizzleDb.schemas.agent.id, agentId),
with: {
databases: true,
},
});
const agent = await db.query.agent.findFirst({ if (!agent) {
where: eq(drizzleDb.schemas.agent.id, agentId), notFound();
with: { }
databases: true
}
})
if (!agent) { const edgeKey = await generateEdgeKey(getServerUrl(), agent.id);
notFound()
}
const edgeKey = await generateEdgeKey(getServerUrl(), agent.id); console.log("edgeKey", edgeKey);
return ( return (
<Page> <Page>
<div className="justify-between gap-2 sm:flex"> <div className="justify-between gap-2 sm:flex">
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full "> <PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="min-w-full md:min-w-fit "> <div className="min-w-full md:min-w-fit ">
{capitalizeFirstLetter(agent.name)} {capitalizeFirstLetter(agent.name)}
</div> </div>
<div className="flex items-center gap-2 md:justify-between w-full "> <div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AgentDialog agent={agent as AgentType & { id: string }} typeTrigger={"edit"}/> <AgentDialog
</div> agent={agent as AgentType & { id: string }}
<div className="flex items-center gap-2"> typeTrigger={"edit"}
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"}/> />
</div>
</div>
</PageTitle>
</div> </div>
<div className="flex items-center gap-2">
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"} />
</div>
</div>
</PageTitle>
</div>
{agent.description && ( {agent.description && (
<PageDescription className="mt-5 sm:mt-0">{agent.description}</PageDescription> <PageDescription className="mt-5 sm:mt-0">
)} {agent.description}
<PageContent className="flex flex-col w-full h-full justify-between gap-6"> </PageDescription>
<AgentContentPage )}
agent={agent} <PageContent className="flex flex-col w-full h-full justify-between gap-6">
edgeKey={edgeKey} <AgentContentPage agent={agent} edgeKey={edgeKey} />
/> </PageContent>
</PageContent> </Page>
</Page> );
)
} }
+57 -41
View File
@@ -1,47 +1,63 @@
name: portabase-dev name: portabase-dev
services: services:
db: db:
image: postgres:17-alpine image: postgres:17-alpine
ports: ports:
- "5433:5432" - "5433:5432"
volumes: volumes:
- postgres-data:/var/lib/postgresql/data - postgres-data:/var/lib/postgresql/data
environment: environment:
- POSTGRES_DB=devdb - POSTGRES_DB=devdb
- POSTGRES_USER=devuser - POSTGRES_USER=devuser
- POSTGRES_PASSWORD=changeme - POSTGRES_PASSWORD=changeme
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U devuser -d devdb"] test: ["CMD-SHELL", "pg_isready -U devuser -d devdb"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
tusd: tusd:
image: tusproject/tusd:v2.8.0 image: tusproject/tusd:v2.8.0
ports: ports:
- "1080:8080" - "1080:8080"
command: > command: >
-upload-dir /data/uploads/tmp -upload-dir /data/uploads/tmp
-hooks-http http://localhost:8887/api/tus/hooks -hooks-http http://localhost:8887/api/tus/hooks
-max-size 21474836480 -max-size 21474836480
-base-path /tus/files/ -base-path /tus/files/
extra_hosts: extra_hosts:
- "localhost:host-gateway" - "localhost:host-gateway"
volumes: volumes:
- ./private/uploads/tmp:/data/uploads/tmp - ./private/uploads/tmp:/data/uploads/tmp
keycloak:
image: quay.io/keycloak/keycloak:latest
command: start-dev
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
ports:
- "8080:8080"
volumes:
- keycloak-data:/opt/keycloak/data
keycloak:
image: quay.io/keycloak/keycloak:latest
command: start-dev
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
ports:
- "8080:8080"
volumes:
- keycloak-data:/opt/keycloak/data
pocket-id:
image: ghcr.io/pocket-id/pocket-id
restart: unless-stopped
environment:
- APP_URL=http://localhost:3055
- ENCRYPTION_KEY=QwHyjbZvSsDUAcjpdmSPsuYxaH6vET6OeBaeLwXccCb43L6Om3W1AoU5pKIJTzYr
ports:
- 3055:1411
volumes:
- pocket-id-data:/app/data
healthcheck:
test: "curl -f http://localhost:1411/healthz"
interval: 1m30s
timeout: 5s
retries: 2
start_period: 10s
volumes: volumes:
postgres-data: postgres-data:
keycloak-data: keycloak-data:
pocket-id-data:
@@ -3,7 +3,7 @@
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Tabs, TabsContent } from "@/components/ui/tabs"; import { Tabs, TabsContent } from "@/components/ui/tabs";
import { ProfileSidebar } from "./profile-sidebar"; import { ProfileSidebar } from "./profile-sidebar";
import { AuthProviderConfig } from "@/lib/auth/config"; import type { AuthProviderConfig } from "@/lib/auth/config";
import { User, Session, Account } from "@/db/schema/02_user"; import { User, Session, Account } from "@/db/schema/02_user";
import { ProfileGeneral } from "../../profile/profile-general"; import { ProfileGeneral } from "../../profile/profile-general";
import { ProfileSecurity } from "../../profile/profile-security"; import { ProfileSecurity } from "../../profile/profile-security";
@@ -45,6 +45,7 @@ export const ProfileModal = ({ user, sessions, currentSession, accounts, open, o
credentialAccount={accounts.find((acc) => acc.providerId === "credential")!} credentialAccount={accounts.find((acc) => acc.providerId === "credential")!}
isPasswordEnabled={providers.some((p) => p.id === "credential")} isPasswordEnabled={providers.some((p) => p.id === "credential")}
isPasskeyEnabled={providers.some((p) => p.id === "passkey")} isPasskeyEnabled={providers.some((p) => p.id === "passkey")}
providers={providers}
/> />
</TabsContent> </TabsContent>
@@ -1,253 +1,311 @@
"use client" "use client";
import {Backup, BackupWith, Restoration} from "@/db/schema/07_database"; import { Backup, BackupWith, Restoration } from "@/db/schema/07_database";
import {Swiper, SwiperSlide} from "swiper/react"; import { Swiper, SwiperSlide } from "swiper/react";
//@ts-ignore //@ts-ignore
import "swiper/css"; import "swiper/css";
import "swiper/css/pagination"; import "swiper/css/pagination";
import {Pagination, Mousewheel} from "swiper/modules"; import { Pagination, Mousewheel } from "swiper/modules";
import {DatabaseActionKind, useBackupModal} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
import { import {
BackupActionsSchema, DatabaseActionKind,
BackupActionsType useBackupModal,
} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
useZodForm,
} from "@/components/ui/form";
import {
BackupActionsSchema,
BackupActionsType,
} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.schema"; } from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.schema";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading"; import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
import {useMutation, useQueryClient} from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import {BackupStorageWith} from "@/db/schema/14_storage-backup"; import { BackupStorageWith } from "@/db/schema/14_storage-backup";
import {TooltipProvider} from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import {getChannelIcon} from "@/components/wrappers/dashboard/admin/channels/helpers/common"; import { getChannelIcon } from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import {Badge} from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import {getStatusColor, getStatusIcon} from "@/components/wrappers/dashboard/admin/notifications/logs/columns";
import {useRouter} from "next/navigation";
import { import {
createRestorationBackupAction, deleteBackupAction, deleteBackupStorageAction, getStatusColor,
downloadBackupAction getStatusIcon,
} from "@/components/wrappers/dashboard/admin/notifications/logs/columns";
import { useRouter } from "next/navigation";
import {
createRestorationBackupAction,
deleteBackupAction,
deleteBackupStorageAction,
downloadBackupAction,
} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.action"; } from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.action";
import {toast} from "sonner"; import { toast } from "sonner";
import {SafeActionResult} from "next-safe-action"; import { SafeActionResult } from "next-safe-action";
import {ServerActionResult} from "@/types/action-type"; import { ServerActionResult } from "@/types/action-type";
import {ZodString} from "zod"; import { ZodString } from "zod";
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {AlertCircleIcon} from "lucide-react"; import { AlertCircleIcon } from "lucide-react";
type BackupActionsFormProps = { type BackupActionsFormProps = {
backup: BackupWith; backup: BackupWith;
action: DatabaseActionKind; action: DatabaseActionKind;
} };
export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => { export const BackupActionsForm = ({
backup,
action,
}: BackupActionsFormProps) => {
const filteredBackupStorages =
backup.storages?.filter((storage) => storage.deletedAt === null) ?? [];
const { closeModal } = useBackupModal();
const queryClient = useQueryClient();
const router = useRouter();
const filteredBackupStorages = backup.storages?.filter((storage) => storage.deletedAt === null) ?? [] const form = useZodForm({
const {closeModal} = useBackupModal(); schema: BackupActionsSchema,
const queryClient = useQueryClient(); });
const router = useRouter();
const form = useZodForm({ const mutation = useMutation({
schema: BackupActionsSchema, mutationFn: async (values: BackupActionsType) => {
}); let result:
| SafeActionResult<
string,
ZodString,
readonly [],
{
_errors?: string[] | undefined;
},
readonly [],
ServerActionResult<string | Restoration | Backup>,
object
>
| undefined;
const mutation = useMutation({ if (action === "download") {
mutationFn: async (values: BackupActionsType) => { result = await downloadBackupAction({
backupStorageId: values.backupStorageId,
});
} else if (action === "restore") {
result = await createRestorationBackupAction({
databaseId: backup.databaseId,
backupStorageId: values.backupStorageId,
backupId: backup.id,
});
} else if (action === "delete") {
result = await deleteBackupStorageAction({
databaseId: backup.databaseId,
backupStorageId: values.backupStorageId,
backupId: backup.id,
});
}
let result: SafeActionResult<string, ZodString, readonly [], { const inner = result?.data;
_errors?: string[] | undefined;
}, readonly [], ServerActionResult<string | Restoration | Backup>, object> | undefined
if (action === "download") { if (inner?.success) {
result = await downloadBackupAction({backupStorageId: values.backupStorageId}) toast.success(inner.actionSuccess?.message);
} else if (action === "restore") { queryClient.invalidateQueries({
result = await createRestorationBackupAction({ queryKey: ["database-data", backup.databaseId],
databaseId: backup.databaseId, });
backupStorageId: values.backupStorageId, router.refresh();
backupId: backup.id if (action === "download") {
}) const url = inner.value;
} else if (action === "delete") { if (typeof url === "string") {
result = await deleteBackupStorageAction({ window.open(url, "_self");
databaseId: backup.databaseId, }
backupStorageId: values.backupStorageId, closeModal();
backupId: backup.id, } else if (action === "restore") {
}) closeModal();
} } else if (action === "delete") {
closeModal();
} else {
closeModal();
}
} else {
if (action === "delete") {
toast.success("Backup deleted successfully.");
queryClient.invalidateQueries({
queryKey: ["database-data", backup.databaseId],
});
router.refresh();
closeModal();
} else {
toast.error(inner?.actionError?.message ?? "An error occurred.");
}
}
},
});
const inner = result?.data; const mutationDeleteEntireBackup = useMutation({
mutationFn: async () => {
const result = await deleteBackupAction({
databaseId: backup.databaseId,
backupId: backup.id,
});
if (inner?.success) { const inner = result?.data;
toast.success(inner.actionSuccess?.message);
queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]});
router.refresh();
if (action === "download") {
const url = inner.value
if (typeof url === "string") {
window.open(url, "_self");
}
closeModal()
} else if (action === "restore") {
closeModal()
} else if (action === "delete") {
closeModal()
} else {
closeModal()
}
} else {
if (action === "delete") {
toast.success("Backup deleted successfully.")
queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]});
router.refresh();
closeModal()
} else {
toast.error(inner?.actionError?.message ?? "An error occurred.");
}
}
},
});
const mutationDeleteEntireBackup = useMutation({ if (inner?.success) {
mutationFn: async () => { toast.success(inner.actionSuccess?.message);
queryClient.invalidateQueries({
queryKey: ["database-data", backup.databaseId],
});
router.refresh();
closeModal();
} else {
toast.error(inner?.actionError?.message);
}
},
});
const result = await deleteBackupAction({ return (
databaseId: backup.databaseId, <TooltipProvider>
backupId: backup.id, <Form
}) form={form}
className="flex flex-col gap-4 mb-1"
const inner = result?.data; onSubmit={async (values) => {
await mutation.mutateAsync(values);
if (inner?.success) { }}
toast.success(inner.actionSuccess?.message); >
queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]}); {filteredBackupStorages.length > 0 ? (
router.refresh(); <FormField
closeModal() control={form.control}
} else { name="backupStorageId"
toast.error(inner?.actionError?.message); render={({ field }) => (
} <FormItem>
}, <FormLabel>Choose a storage backup</FormLabel>
}); <FormControl>
<div style={{ height: "250px" }}>
return ( <Swiper
<TooltipProvider> direction="vertical"
slidesPerView={3.5}
spaceBetween={10}
<Form // pagination={{ clickable: true }}
form={form} mousewheel={{ releaseOnEdges: true, forceToAxis: true }}
className="flex flex-col gap-4 mb-1" modules={[Pagination, Mousewheel]}
onSubmit={async (values) => { className="mySwiper"
await mutation.mutateAsync(values); style={{ height: "100%" }}
}} >
> {filteredBackupStorages.map(
(storage: BackupStorageWith) => (
{filteredBackupStorages.length > 0 ? <SwiperSlide key={storage.id}>
<FormField <button
control={form.control} disabled={
name="backupStorageId" action !== "delete" &&
render={({field}) => ( storage.status.toLowerCase() !== "success"
<FormItem> }
<FormLabel>Choose a storage backup</FormLabel> type="button"
<FormControl> onClick={() => field.onChange(storage.id)}
className={`w-full h-full flex items-start gap-3 p-4 rounded-lg border text-left transition-colors
<div style={{height: "250px"}}> ${
<Swiper field.value ===
direction="vertical" storage.id
slidesPerView={3.5}
spaceBetween={10}
// pagination={{ clickable: true }}
mousewheel={{releaseOnEdges: true, forceToAxis: true}}
modules={[Pagination, Mousewheel]}
className="mySwiper"
style={{height: "100%"}}
>
{filteredBackupStorages.map((storage: BackupStorageWith) => (
<SwiperSlide key={storage.id}>
<button
disabled={action !== "delete" && storage.status.toLowerCase() !== "success"}
type="button"
onClick={() => field.onChange(storage.id)}
className={`w-full h-full flex items-start gap-3 p-4 rounded-lg border text-left transition-colors
${field.value === storage.id
? "border-foreground bg-background" ? "border-foreground bg-background"
: "border-border bg-background" + ((storage.status.toLowerCase() === "success" || action === "delete") ? " hover:border-muted-foreground" : "")} : "border-border bg-background" +
(storage.status.toLowerCase() ===
"success" ||
action ===
"delete"
? " hover:border-muted-foreground"
: "")
}
${storage.status.toLowerCase() !== "success" && action !== "delete" ? "opacity-50 cursor-not-allowed" : ""}`} ${storage.status.toLowerCase() !== "success" && action !== "delete" ? "opacity-50 cursor-not-allowed" : ""}`}
> >
<div <div
className={`mt-0.5 h-4 w-4 shrink-0 rounded-full border ${ className={`mt-0.5 h-4 w-4 shrink-0 rounded-full border ${
field.value === storage.id ? "border-foreground" : "border-muted-foreground" field.value === storage.id
} flex items-center justify-center`} ? "border-foreground"
> : "border-muted-foreground"
{field.value === storage.id && } flex items-center justify-center`}
<div className="h-2 w-2 rounded-full bg-foreground"/>} >
</div> {field.value === storage.id && (
<div className="flex-1 min-w-0"> <div className="h-2 w-2 rounded-full bg-foreground" />
<div className="flex items-center gap-2"> )}
<div className="flex-1 min-w-0 flex flex-col gap-1"> </div>
<div className="flex items-center justify-between gap-2"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2">
<div className="shrink-0"> <div className="flex-1 min-w-0 flex flex-col gap-1">
{getChannelIcon(storage.storageChannel?.provider || "")} <div className="flex items-center justify-between gap-2 min-w-0 w-full">
</div> <div className="flex items-center gap-2 min-w-0 flex-1">
<h3 className="font-medium text-foreground truncate"> <div className="shrink-0">
{storage.storageChannel?.name} {getChannelIcon(
</h3> storage.storageChannel?.provider ||
<Badge variant="secondary" "",
className="text-xs font-mono shrink-0"> )}
{storage.storageChannel?.provider} </div>
</Badge> <h3 className="font-medium text-foreground truncate min-w-0">
</div> {storage.storageChannel?.name}
<Badge variant="outline" </h3>
className={`gap-1.5 shrink-0 ${getStatusColor(storage.status)}`}> <Badge
{getStatusIcon(storage.status === "success")} variant="secondary"
<span className="text-xs font-mono shrink-0"
className="capitalize">{storage.status.toUpperCase()}</span> >
</Badge> {storage.storageChannel?.provider}
</div> </Badge>
</div> </div>
</div> <Badge
</div> variant="outline"
</button> className={`gap-1.5 shrink-0 ${getStatusColor(storage.status)}`}
</SwiperSlide> >
)) ?? <p>No storages available</p>} {getStatusIcon(
</Swiper> storage.status === "success",
)}
<span className="capitalize">
{storage.status.toUpperCase()}
</span>
</Badge>
</div> </div>
</FormControl> </div>
<FormMessage/> </div>
</FormItem> </div>
)} </button>
/> </SwiperSlide>
),
) ?? <p>No storages available</p>}
</Swiper>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
) : (
<Alert>
<AlertCircleIcon />
<AlertTitle>Backup does not have files</AlertTitle>
<AlertDescription>
<p>
You can safely delete the entire backup; no files seem to be
related. Maybe an error occurred.
</p>
</AlertDescription>
</Alert>
)}
: <div className="flex flex-row items-center gap-x-4 w-full">
<Alert> {action === "delete" && (
<AlertCircleIcon/> <ButtonWithLoading
<AlertTitle>Backup does not have files</AlertTitle> type="button"
<AlertDescription> variant="destructive"
<p>You can safely delete the entire backup; no files seem to be related. Maybe an error onClick={() => mutationDeleteEntireBackup.mutateAsync()}
occurred.</p> isPending={mutationDeleteEntireBackup.isPending}
</AlertDescription> disabled={mutationDeleteEntireBackup.isPending}
</Alert> >
} Delete entire backup
</ButtonWithLoading>
)}
<div className="flex flex-row items-center gap-x-4 w-full"> {filteredBackupStorages.length > 0 && (
{action === "delete" && ( <ButtonWithLoading
<ButtonWithLoading type="submit"
type="button" isPending={mutation.isPending}
variant="destructive" disabled={mutation.isPending}
onClick={() => mutationDeleteEntireBackup.mutateAsync()} className="ml-auto"
isPending={mutationDeleteEntireBackup.isPending} >
disabled={mutationDeleteEntireBackup.isPending} Confirm
> </ButtonWithLoading>
Delete entire backup )}
</ButtonWithLoading> </div>
)} </Form>
</TooltipProvider>
{filteredBackupStorages.length > 0 && ( );
<ButtonWithLoading };
type="submit"
isPending={mutation.isPending}
disabled={mutation.isPending}
className="ml-auto"
>
Confirm
</ButtonWithLoading>
)}
</div>
</Form>
</TooltipProvider>
);
}
@@ -13,7 +13,7 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
import { SetPasswordProfileProviderModal } from "./modal/set-password-modal"; import { SetPasswordProfileProviderModal } from "./modal/set-password-modal";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import Image from "next/image"; import Image from "next/image";
import { AuthProviderConfig } from "@/lib/auth/config"; import type { AuthProviderConfig } from "@/lib/auth/config";
import { Account } from "@/db/schema/02_user"; import { Account } from "@/db/schema/02_user";
interface ProfileProviderProps { interface ProfileProviderProps {
@@ -122,16 +122,16 @@ export function ProfileProviders({ accounts, providers }: ProfileProviderProps)
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => unlinkAccount(provider.id)} onClick={() => unlinkAccount(provider.id)}
disabled={!canUnlink || isLoading || provider.isManual} disabled={!canUnlink || isLoading || provider.isManual || provider.allowUnlinking === false}
className={!canUnlink ? "opacity-50 cursor-not-allowed" : ""} className={!canUnlink || provider.allowUnlinking === false ? "opacity-50 cursor-not-allowed" : ""}
> >
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : "Unlink"} {isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : "Unlink"}
</Button> </Button>
</span> </span>
</TooltipTrigger> </TooltipTrigger>
{!canUnlink && ( {(!canUnlink || provider.allowUnlinking === false) && (
<TooltipContent> <TooltipContent>
<p>You cannot unlink your last authentication provider.</p> <p>{provider.allowUnlinking === false ? "Unlinking is disabled for this provider." : "You cannot unlink your last authentication provider."}</p>
</TooltipContent> </TooltipContent>
)} )}
</Tooltip> </Tooltip>
@@ -141,7 +141,7 @@ export function ProfileProviders({ accounts, providers }: ProfileProviderProps)
{provider.id === "credential" ? ( {provider.id === "credential" ? (
<SetPasswordProfileProviderModal open={isPasswordDialogOpen} onOpenChange={setIsPasswordDialogOpen} /> <SetPasswordProfileProviderModal open={isPasswordDialogOpen} onOpenChange={setIsPasswordDialogOpen} />
) : ( ) : (
<Button variant="default" size="sm" onClick={() => linkAccount(provider)} disabled={isLoading || provider.isManual}> <Button variant="default" size="sm" onClick={() => linkAccount(provider)} disabled={isLoading || provider.isManual || provider.allowLinking === false}>
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : "Link"} {isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : "Link"}
</Button> </Button>
)} )}
@@ -4,10 +4,22 @@ import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Globe, LogOut, Loader2, Fingerprint, Trash2, Plus } from "lucide-react"; import {
Globe,
LogOut,
Loader2,
Fingerprint,
Trash2,
Plus,
} from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { revokeAllSessionsAction, revokeSessionAction, getPasskeysAction, revokePasskeyAction } from "./actions/security.action"; import {
revokeAllSessionsAction,
revokeSessionAction,
getPasskeysAction,
revokePasskeyAction,
} from "./actions/security.action";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ResetPasswordProfileProviderModal } from "./modal/reset-password-modal"; import { ResetPasswordProfileProviderModal } from "./modal/reset-password-modal";
import { SetPasswordProfileProviderModal } from "./modal/set-password-modal"; import { SetPasswordProfileProviderModal } from "./modal/set-password-modal";
@@ -17,335 +29,459 @@ import { ViewBackupCodesModal } from "./modal/view-backup-codes-modal";
import { getDeviceDetails } from "@/utils/detection"; import { getDeviceDetails } from "@/utils/detection";
import { timeAgo } from "@/utils/date-formatting"; import { timeAgo } from "@/utils/date-formatting";
import { authClient } from "@/lib/auth/auth-client"; import { authClient } from "@/lib/auth/auth-client";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Account, Session, User } from "@/db/schema/02_user"; import { Account, Session, User } from "@/db/schema/02_user";
import { Icon } from "@iconify/react";
import Image from "next/image";
import type { AuthProviderConfig } from "@/lib/auth/config";
interface ProfileSecurityProps { interface ProfileSecurityProps {
user: User; user: User;
sessions: Session[]; sessions: Session[];
credentialAccount: Account; credentialAccount: Account;
currentSession: Session; currentSession: Session;
isPasswordEnabled?: boolean; isPasswordEnabled?: boolean;
isPasskeyEnabled?: boolean; isPasskeyEnabled?: boolean;
providers: AuthProviderConfig[];
} }
export function ProfileSecurity({ user, sessions, credentialAccount, currentSession, isPasswordEnabled = false, isPasskeyEnabled = false }: ProfileSecurityProps) { export function ProfileSecurity({
const router = useRouter(); user,
sessions,
credentialAccount,
currentSession,
isPasswordEnabled = false,
isPasskeyEnabled = false,
providers,
}: ProfileSecurityProps) {
const router = useRouter();
const [isBackupCodesDialogOpen, setIsBackupCodesDialogOpen] = useState(false); const [isBackupCodesDialogOpen, setIsBackupCodesDialogOpen] = useState(false);
const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false); const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false);
const [isSetup2FADialogOpen, setIsSetup2FADialogOpen] = useState(false); const [isSetup2FADialogOpen, setIsSetup2FADialogOpen] = useState(false);
const [isDisable2FADialogOpen, setIsDisable2FADialogOpen] = useState(false); const [isDisable2FADialogOpen, setIsDisable2FADialogOpen] = useState(false);
const [isAddPasskeyOpen, setIsAddPasskeyOpen] = useState(false); const [isAddPasskeyOpen, setIsAddPasskeyOpen] = useState(false);
const [passkeyName, setPasskeyName] = useState(""); const [passkeyName, setPasskeyName] = useState("");
const { mutate: revokeSession, isPending: isRevoking } = useMutation({ const { mutate: revokeSession, isPending: isRevoking } = useMutation({
mutationFn: async (token: string) => { mutationFn: async (token: string) => {
const result = await revokeSessionAction({ token }); const result = await revokeSessionAction({ token });
const inner = result?.data; const inner = result?.data;
if (inner?.success) { if (inner?.success) {
toast.success("Session successfully revoked"); toast.success("Session successfully revoked");
router.refresh(); router.refresh();
} else { } else {
toast.error("An error occurred while revoking session"); toast.error("An error occurred while revoking session");
} }
}, },
}); });
const { mutate: revokeOthers, isPending: isRevokingOthers } = useMutation({ const { mutate: revokeOthers, isPending: isRevokingOthers } = useMutation({
mutationFn: async () => { mutationFn: async () => {
const result = await revokeAllSessionsAction(); const result = await revokeAllSessionsAction();
const inner = result?.data; const inner = result?.data;
if (inner?.success) { if (inner?.success) {
toast.success("Revoking all sessions successfully done."); toast.success("Revoking all sessions successfully done.");
router.refresh(); router.refresh();
} else { } else {
toast.error("An error occurred while revoking all sessions"); toast.error("An error occurred while revoking all sessions");
} }
}, },
}); });
const { const {
data: passkeys, data: passkeys,
isLoading: isLoadingPasskeys, isLoading: isLoadingPasskeys,
refetch: refetchPasskeys, refetch: refetchPasskeys,
} = useQuery({ } = useQuery({
queryKey: ["passkeys"], queryKey: ["passkeys"],
queryFn: async () => { queryFn: async () => {
const result = await getPasskeysAction(); const result = await getPasskeysAction();
if (result?.data?.success) { if (result?.data?.success) {
return result.data.value; return result.data.value;
} }
throw new Error("Failed to fetch passkeys"); throw new Error("Failed to fetch passkeys");
}, },
}); });
const { mutate: revokePasskey, isPending: isRevokingPasskey } = useMutation({ const { mutate: revokePasskey, isPending: isRevokingPasskey } = useMutation({
mutationFn: async (id: string) => { mutationFn: async (id: string) => {
const result = await revokePasskeyAction({ id }); const result = await revokePasskeyAction({ id });
if (!result?.data?.success) { if (!result?.data?.success) {
throw new Error("Failed to revoke passkey"); throw new Error("Failed to revoke passkey");
} }
}, },
onSuccess: () => { onSuccess: () => {
toast.success("Passkey revoked successfully"); toast.success("Passkey revoked successfully");
refetchPasskeys(); refetchPasskeys();
}, },
onError: () => { onError: () => {
toast.error("Failed to revoke passkey"); toast.error("Failed to revoke passkey");
}, },
}); });
const { mutate: addPasskey, isPending: isAddingPasskey } = useMutation({ const { mutate: addPasskey, isPending: isAddingPasskey } = useMutation({
mutationFn: async () => { mutationFn: async () => {
const result = await authClient.passkey.addPasskey({ const result = await authClient.passkey.addPasskey({
name: passkeyName || "My Passkey", name: passkeyName || "My Passkey",
}); });
if (result?.error) { if (result?.error) {
throw result.error; throw result.error;
} }
return result; return result;
}, },
onSuccess: () => { onSuccess: () => {
toast.success("Passkey added successfully"); toast.success("Passkey added successfully");
setIsAddPasskeyOpen(false); setIsAddPasskeyOpen(false);
setPasskeyName(""); setPasskeyName("");
refetchPasskeys(); refetchPasskeys();
}, },
onError: (error: any) => { onError: (error: any) => {
toast.error(error.message || "Failed to add passkey"); toast.error(error.message || "Failed to add passkey");
}, },
}); });
return ( return (
<div className="space-y-8 animate-in fade-in-50 duration-300"> <div className="space-y-8 animate-in fade-in-50 duration-300">
<div className="mb-6 space-y-1"> <div className="mb-6 space-y-1">
<h2 className="text-2xl font-semibold tracking-tight">Security Settings</h2> <h2 className="text-2xl font-semibold tracking-tight">
<p className="text-sm text-muted-foreground">Manage your password, two-factor authentication and sessions.</p> Security Settings
</h2>
<p className="text-sm text-muted-foreground">
Manage your password, two-factor authentication and sessions.
</p>
</div>
<div className="space-y-6">
<h3 className="text-lg font-medium">Authentication</h3>
<div className="border rounded-lg p-4 space-y-4">
{isPasswordEnabled && (
<>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="space-y-1">
<div className="font-medium">Password</div>
<div className="text-sm text-muted-foreground">
{user.lastChangedPasswordAt
? `Last changed ${timeAgo(new Date(user.lastChangedPasswordAt))}`
: "Never changed"}
</div>
</div>
{credentialAccount ? (
<ResetPasswordProfileProviderModal
open={isPasswordDialogOpen}
onOpenChange={setIsPasswordDialogOpen}
/>
) : (
<SetPasswordProfileProviderModal
open={isPasswordDialogOpen}
onOpenChange={setIsPasswordDialogOpen}
/>
)}
</div>
<Separator />
</>
)}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<div className="font-medium">Two-Factor Authentication</div>
{user.twoFactorEnabled && (
<Badge
variant="secondary"
className="text-[10px] h-5 px-1.5 text-green-600 bg-green-500/10 border-0"
>
Active
</Badge>
)}
</div>
<div className="text-sm text-muted-foreground">
Enhance the security of your account by requiring a second form
of verification during login.
</div>
</div> </div>
<div className="space-y-6"> {user.twoFactorEnabled ? (
<h3 className="text-lg font-medium">Authentication</h3> <div className="flex flex-col items-center gap-2">
<div className="border rounded-lg p-4 space-y-4"> <ViewBackupCodesModal
{isPasswordEnabled && ( open={isBackupCodesDialogOpen}
<> onOpenChange={setIsBackupCodesDialogOpen}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> />
<div className="space-y-1"> <Disable2FAProfileProviderModal
<div className="font-medium">Password</div> open={isDisable2FADialogOpen}
<div className="text-sm text-muted-foreground"> onOpenChange={setIsDisable2FADialogOpen}
{user.lastChangedPasswordAt ? `Last changed ${timeAgo(new Date(user.lastChangedPasswordAt))}` : "Never changed"} />
</div> </div>
</div> ) : (
{credentialAccount ? ( <Setup2FAProfileProviderModal
<ResetPasswordProfileProviderModal open={isPasswordDialogOpen} onOpenChange={setIsPasswordDialogOpen} /> disabled={!credentialAccount}
) : ( open={isSetup2FADialogOpen}
<SetPasswordProfileProviderModal open={isPasswordDialogOpen} onOpenChange={setIsPasswordDialogOpen} /> onOpenChange={setIsSetup2FADialogOpen}
)} />
</div>
<Separator />
</>
)}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<div className="font-medium">Two-Factor Authentication</div>
{user.twoFactorEnabled && (
<Badge variant="secondary" className="text-[10px] h-5 px-1.5 text-green-600 bg-green-500/10 border-0">
Active
</Badge>
)}
</div>
<div className="text-sm text-muted-foreground">
Enhance the security of your account by requiring a second form of verification during login.
</div>
</div>
{user.twoFactorEnabled ? (
<div className="flex flex-col items-center gap-2">
<ViewBackupCodesModal open={isBackupCodesDialogOpen} onOpenChange={setIsBackupCodesDialogOpen} />
<Disable2FAProfileProviderModal open={isDisable2FADialogOpen} onOpenChange={setIsDisable2FADialogOpen} />
</div>
) : (
<Setup2FAProfileProviderModal disabled={!credentialAccount} open={isSetup2FADialogOpen} onOpenChange={setIsSetup2FADialogOpen} />
)}
</div>
</div>
</div>
{isPasskeyEnabled && (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h3 className="text-lg font-medium">Passkeys</h3>
<div className="text-sm text-muted-foreground">Login securely with your fingerprint, face recognition, or hardware key.</div>
</div>
<Dialog open={isAddPasskeyOpen} onOpenChange={setIsAddPasskeyOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Plus className="mr-2 h-4 w-4" />
Add Passkey
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add New Passkey</DialogTitle>
<DialogDescription>Create a name for your passkey to identify it later.</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Passkey Name</Label>
<Input
id="name"
placeholder="e.g. MacBook Pro, iPhone, YubiKey"
value={passkeyName}
onChange={(e) => setPasskeyName(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddPasskeyOpen(false)}>
Cancel
</Button>
<Button onClick={() => addPasskey()} disabled={isAddingPasskey}>
{isAddingPasskey && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Passkey
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<div className="border rounded-lg divide-y">
{isLoadingPasskeys ? (
<div className="flex items-center justify-center p-4">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : passkeys && passkeys.length > 0 ? (
passkeys.map((pk: any) => <PasskeyRow key={pk.id} passkey={pk} onRevoke={(id) => revokePasskey(id)} isRevoking={isRevokingPasskey} />)
) : (
<div className="p-4 text-center text-muted-foreground">No passkeys found.</div>
)}
</div>
</div>
)} )}
</div>
<div className="space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium">Active Sessions</h3>
{sessions && sessions.length > 1 && (
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => revokeOthers()}
disabled={isRevokingOthers || (sessions?.length || 0) <= 1}
>
{isRevokingOthers && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Revoke All
</Button>
)}
</div>
<div className="border rounded-lg divide-y">
{sessions && sessions.length > 0 ? (
sessions?.map((session) => (
<SessionRow
key={session.id}
session={session}
onRevoke={(token) => revokeSession(token)}
isRevoking={isRevoking}
currentSession={currentSession}
/>
))
) : (
<div className="p-4 text-center text-muted-foreground">No active sessions found.</div>
)}
</div>
</div>
</div> </div>
); </div>
{isPasskeyEnabled && (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h3 className="text-lg font-medium">Passkeys</h3>
<div className="text-sm text-muted-foreground">
Login securely with your fingerprint, face recognition, or
hardware key.
</div>
</div>
<Dialog open={isAddPasskeyOpen} onOpenChange={setIsAddPasskeyOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Plus className="mr-2 h-4 w-4" />
Add Passkey
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add New Passkey</DialogTitle>
<DialogDescription>
Create a name for your passkey to identify it later.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Passkey Name</Label>
<Input
id="name"
placeholder="e.g. MacBook Pro, iPhone, YubiKey"
value={passkeyName}
onChange={(e) => setPasskeyName(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsAddPasskeyOpen(false)}
>
Cancel
</Button>
<Button
onClick={() => addPasskey()}
disabled={isAddingPasskey}
>
{isAddingPasskey && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Create Passkey
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<div className="border rounded-lg divide-y">
{isLoadingPasskeys ? (
<div className="flex items-center justify-center p-4">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : passkeys && passkeys.length > 0 ? (
passkeys.map((pk: any) => (
<PasskeyRow
key={pk.id}
passkey={pk}
onRevoke={(id) => revokePasskey(id)}
isRevoking={isRevokingPasskey}
/>
))
) : (
<div className="p-4 text-center text-muted-foreground">
No passkeys found.
</div>
)}
</div>
</div>
)}
<div className="space-y-6 pb-10">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium">Active Sessions</h3>
{sessions && sessions.length > 1 && (
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => revokeOthers()}
disabled={isRevokingOthers || (sessions?.length || 0) <= 1}
>
{isRevokingOthers && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Revoke All
</Button>
)}
</div>
<div className="border rounded-lg divide-y">
{sessions && sessions.length > 0 ? (
sessions?.map((session) => (
<SessionRow
key={session.id}
session={session}
onRevoke={(token) => revokeSession(token)}
isRevoking={isRevoking}
currentSession={currentSession}
providers={providers}
/>
))
) : (
<div className="p-4 text-center text-muted-foreground">
No active sessions found.
</div>
)}
</div>
</div>
</div>
);
} }
function SessionRow({ function SessionRow({
session, session,
onRevoke, onRevoke,
isRevoking, isRevoking,
currentSession, currentSession,
providers,
}: { }: {
session: Session; session: Session;
onRevoke: (token: string) => void; onRevoke: (token: string) => void;
isRevoking: boolean; isRevoking: boolean;
currentSession: Session; currentSession: Session;
providers: AuthProviderConfig[];
}) { }) {
const deviceInfo = getDeviceDetails(session.userAgent); const deviceInfo = getDeviceDetails(session.userAgent);
const provider = providers.find((p) => p.id === (session as any).providerId);
return ( return (
<div className="flex items-center justify-between p-4"> <div className="flex items-center justify-between p-4">
<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 text-muted-foreground"> <div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center text-muted-foreground relative">
<deviceInfo.Icon className="w-5 h-5" /> <deviceInfo.Icon className="w-5 h-5" />
</div> {provider && (
<div className="space-y-0.5"> <div className="absolute -bottom-1 -right-1 w-5 h-5 rounded-full bg-background border flex items-center justify-center overflow-hidden">
<div className="text-sm font-medium flex items-center gap-2"> {provider.icon.startsWith("/") || provider.icon.startsWith("http") ? (
{deviceInfo.os} <span className="text-muted-foreground font-normal"> {deviceInfo.browser}</span> <Image
{session.id === currentSession.id && ( src={provider.icon}
<Badge alt={provider.id}
variant="outline" width={12}
className="text-[10px] h-5 px-1.5 text-sky-600 bg-sky-50 border-sky-200 dark:bg-sky-900/20 dark:border-sky-800 dark:text-sky-400" height={12}
> className="w-3 h-3"
This device unoptimized={provider.icon.startsWith("http")}
</Badge> />
)} ) : (
</div> <Icon icon={provider.icon} className="w-3 h-3" />
<div className="text-xs text-muted-foreground flex items-center gap-1"> )}
<Globe className="w-3 h-3" /> {session.ipAddress} </div>
<span className="ml-1">{session.id === currentSession.id ? "Active now" : `Last active ${timeAgo(new Date(session.createdAt))}`}</span> )}
</div> </div>
</div> <div className="space-y-0.5">
</div> <div className="text-sm font-medium flex items-center gap-2">
{deviceInfo.os}{" "}
{session.id !== currentSession.id && ( <span className="text-muted-foreground font-normal">
<Button {deviceInfo.browser}
variant="ghost" </span>
size="icon" {provider && (
className="h-8 w-8 text-muted-foreground hover:text-destructive" <span className="text-muted-foreground font-normal">
onClick={() => onRevoke(session.token)} {provider.title || provider.name}
disabled={isRevoking} </span>
>
{isRevoking ? <Loader2 className="h-4 w-4 animate-spin" /> : <LogOut className="w-4 h-4" />}
<span className="sr-only">Revoke</span>
</Button>
)} )}
{session.id === currentSession.id && (
<Badge
variant="outline"
className="text-[10px] h-5 px-1.5 text-sky-600 bg-sky-50 border-sky-200 dark:bg-sky-900/20 dark:border-sky-800 dark:text-sky-400"
>
This device
</Badge>
)}
</div>
<div className="text-xs text-muted-foreground flex items-center gap-1">
<Globe className="w-3 h-3" /> {session.ipAddress}
<span className="ml-1">
{session.id === currentSession.id
? "Active now"
: `Last active ${timeAgo(new Date(session.createdAt))}`}
</span>
</div>
</div> </div>
); </div>
{session.id !== currentSession.id && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => onRevoke(session.token)}
disabled={isRevoking}
>
{isRevoking ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<LogOut className="w-4 h-4" />
)}
<span className="sr-only">Revoke</span>
</Button>
)}
</div>
);
} }
function PasskeyRow({ passkey, onRevoke, isRevoking }: { passkey: any; onRevoke: (id: string) => void; isRevoking: boolean }) { function PasskeyRow({
return ( passkey,
<div className="flex items-center justify-between p-4"> onRevoke,
<div className="flex items-center gap-4"> isRevoking,
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center text-muted-foreground"> }: {
<Fingerprint className="w-5 h-5" /> passkey: any;
</div> onRevoke: (id: string) => void;
<div className="space-y-0.5"> isRevoking: boolean;
<div className="font-medium text-sm">{passkey.name || "Unnamed Passkey"}</div> }) {
<div className="text-xs text-muted-foreground">Created {timeAgo(new Date(passkey.createdAt))}</div> return (
</div> <div className="flex items-center justify-between p-4">
</div> <div className="flex items-center gap-4">
<Button <div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center text-muted-foreground">
variant="ghost" <Fingerprint className="w-5 h-5" />
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => onRevoke(passkey.id)}
disabled={isRevoking}
>
{isRevoking ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
<span className="sr-only">Revoke</span>
</Button>
</div> </div>
); <div className="space-y-0.5">
<div className="font-medium text-sm">
{passkey.name || "Unnamed Passkey"}
</div>
<div className="text-xs text-muted-foreground">
Created {timeAgo(new Date(passkey.createdAt))}
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => onRevoke(passkey.id)}
disabled={isRevoking}
>
{isRevoking ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
<span className="sr-only">Revoke</span>
</Button>
</div>
);
} }
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "session" ADD COLUMN "provider_id" text;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -253,6 +253,13 @@
"when": 1770993283219, "when": 1770993283219,
"tag": "0035_windy_shockwave", "tag": "0035_windy_shockwave",
"breakpoints": true "breakpoints": true
},
{
"idx": 36,
"version": "7",
"when": 1771842940506,
"tag": "0036_left_longshot",
"breakpoints": true
} }
] ]
} }
+1 -1
View File
@@ -38,10 +38,10 @@ export const session = pgTable("session", {
userId: uuid("user_id") userId: uuid("user_id")
.notNull() .notNull()
.references(() => user.id, {onDelete: "cascade"}), .references(() => user.id, {onDelete: "cascade"}),
providerId: text("provider_id"),
impersonatedBy: text("impersonated_by"), //id or name ???? impersonatedBy: text("impersonated_by"), //id or name ????
activeOrganizationId: text("active_organization_id"), activeOrganizationId: text("active_organization_id"),
...timestamps ...timestamps
}); });
export const account = pgTable("account", { export const account = pgTable("account", {
+86 -80
View File
@@ -1,107 +1,113 @@
import { createEnv } from "@t3-oss/env-nextjs"; import { createEnv } from "@t3-oss/env-nextjs";
import path from "path";
import { z } from "zod"; import { z } from "zod";
import packageJson from "../package.json" with { type: "json" }; import packageJson from "../package.json" with { type: "json" };
import path from "path";
const { version } = packageJson; const { version } = packageJson;
export const env = createEnv({ export const env = createEnv({
server: { server: {
NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(), NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(),
NODE_ENV: z.enum(["development", "production"]).optional(), NODE_ENV: z.enum(["development", "production"]).optional(),
DATABASE_URL: z.string().url().optional(), DATABASE_URL: z.string().url().optional(),
PROJECT_NAME: z.string().optional(), PROJECT_NAME: z.string().optional(),
PROJECT_DESCRIPTION: z.string().optional(), PROJECT_DESCRIPTION: z.string().optional(),
PROJECT_URL: z.string().regex(/^https?:\/\//, "URL must start with http:// or https://"), PROJECT_URL: z
PROJECT_SECRET: z.string(), .string()
.regex(/^https?:\/\//, "URL must start with http:// or https://"),
PROJECT_SECRET: z.string(),
SMTP_PASSWORD: z.string().optional(), SMTP_PASSWORD: z.string().optional(),
SMTP_FROM: z.string().optional(), SMTP_FROM: z.string().optional(),
SMTP_HOST: z.string().optional(), SMTP_HOST: z.string().optional(),
SMTP_PORT: z.string().optional(), SMTP_PORT: z.string().optional(),
SMTP_USER: z.string().optional(), SMTP_USER: z.string().optional(),
SMTP_SECURE: z.coerce.boolean().default(true), SMTP_SECURE: z.coerce.boolean().default(true),
AUTH_GOOGLE_ID: z.string().optional(), AUTH_GOOGLE_ID: z.string().optional(),
AUTH_GOOGLE_SECRET: z.string().optional(), AUTH_GOOGLE_SECRET: z.string().optional(),
AUTH_GOOGLE_METHOD: z.boolean().default(false), AUTH_GOOGLE_METHOD: z.boolean().default(false),
AUTH_GITHUB_ID: z.string().optional(), AUTH_GITHUB_ID: z.string().optional(),
AUTH_GITHUB_SECRET: z.string().optional(), AUTH_GITHUB_SECRET: z.string().optional(),
RETENTION_CRON: z.string().default(process.env.NODE_ENV === "production" ? "0 7 * * *" : "* * * * *"), RETENTION_CRON: z
.string()
.default(
process.env.NODE_ENV === "production" ? "0 7 * * *" : "* * * * *",
),
AUTH_OIDC_ID: z.string().optional().default("oidc"), AUTH_OIDC_ID: z.string().optional().default("oidc"),
AUTH_OIDC_TITLE: z.string().optional(), AUTH_OIDC_TITLE: z.string().optional(),
AUTH_OIDC_DESC: z.string().optional(), AUTH_OIDC_DESC: z.string().optional(),
AUTH_OIDC_ICON: z.string().optional(), AUTH_OIDC_ICON: z.string().optional(),
AUTH_OIDC_CLIENT: z.string().optional(), AUTH_OIDC_CLIENT: z.string().optional(),
AUTH_OIDC_SECRET: z.string().optional(), AUTH_OIDC_SECRET: z.string().optional(),
AUTH_OIDC_ISSUER_URL: z.string().optional(), AUTH_OIDC_ISSUER_URL: z.string().optional(),
AUTH_OIDC_HOST: z.string().optional(), AUTH_OIDC_HOST: z.string().optional(),
AUTH_OIDC_SCOPES: z.string().optional(), AUTH_OIDC_SCOPES: z.string().optional(),
AUTH_OIDC_DISCOVERY_ENDPOINT: z.string().optional(), AUTH_OIDC_DISCOVERY_ENDPOINT: z.string().optional(),
AUTH_OIDC_JWKS_ENDPOINT: z.string().optional(), AUTH_OIDC_JWKS_ENDPOINT: z.string().optional(),
AUTH_OIDC_PKCE: z.string().optional(), AUTH_OIDC_PKCE: z.string().optional(),
ALLOWED_GROUP: z.string().optional(), ALLOWED_GROUP: z.string().optional(),
AUTH_EMAIL_PASSWORD_ENABLED: z.string().optional().default("true"), AUTH_EMAIL_PASSWORD_ENABLED: z.string().optional().default("true"),
AUTH_SIGNUP_ENABLED: z.string().optional().default("true"), AUTH_SIGNUP_ENABLED: z.string().optional().default("true"),
AUTH_PASSKEY_ENABLED: z.string().optional().default("true"), AUTH_PASSKEY_ENABLED: z.string().optional().default("true"),
PRIVATE_PATH: z.string().optional(), PRIVATE_PATH: z.string().optional(),
},
client: {
NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(),
},
runtimeEnv: {
NEXT_PUBLIC_PROJECT_VERSION: version || "Unknown Version",
}, PROJECT_NAME: process.env.PROJECT_NAME,
client: { PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION,
NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(), PROJECT_URL: process.env.PROJECT_URL,
}, PROJECT_SECRET: process.env.PROJECT_SECRET,
runtimeEnv: {
NEXT_PUBLIC_PROJECT_VERSION: version || "Unknown Version",
PROJECT_NAME: process.env.PROJECT_NAME, DATABASE_URL: process.env.DATABASE_URL,
PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION,
PROJECT_URL: process.env.PROJECT_URL,
PROJECT_SECRET: process.env.PROJECT_SECRET,
DATABASE_URL: process.env.DATABASE_URL, SMTP_PASSWORD: process.env.SMTP_PASSWORD,
SMTP_FROM: process.env.SMTP_FROM,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_USER: process.env.SMTP_USER,
SMTP_SECURE: process.env.SMTP_SECURE,
SMTP_PASSWORD: process.env.SMTP_PASSWORD, AUTH_GOOGLE_ID: process.env.AUTH_GOOGLE_ID,
SMTP_FROM: process.env.SMTP_FROM, AUTH_GOOGLE_SECRET: process.env.AUTH_GOOGLE_SECRET,
SMTP_HOST: process.env.SMTP_HOST, AUTH_GOOGLE_METHOD: process.env.AUTH_GOOGLE_METHOD === "true",
SMTP_PORT: process.env.SMTP_PORT,
SMTP_USER: process.env.SMTP_USER,
SMTP_SECURE: process.env.SMTP_SECURE,
AUTH_GOOGLE_ID: process.env.AUTH_GOOGLE_ID, AUTH_GITHUB_ID: process.env.AUTH_GITHUB_ID,
AUTH_GOOGLE_SECRET: process.env.AUTH_GOOGLE_SECRET, AUTH_GITHUB_SECRET: process.env.AUTH_GITHUB_SECRET,
AUTH_GOOGLE_METHOD: process.env.AUTH_GOOGLE_METHOD === "true",
AUTH_GITHUB_ID: process.env.AUTH_GITHUB_ID, RETENTION_CRON: process.env.RETENTION_CRON,
AUTH_GITHUB_SECRET: process.env.AUTH_GITHUB_SECRET,
RETENTION_CRON: process.env.RETENTION_CRON, AUTH_OIDC_ID: process.env.AUTH_OIDC_ID,
AUTH_OIDC_TITLE: process.env.AUTH_OIDC_TITLE,
AUTH_OIDC_DESC: process.env.AUTH_OIDC_DESC,
AUTH_OIDC_ICON: process.env.AUTH_OIDC_ICON,
AUTH_OIDC_CLIENT: process.env.AUTH_OIDC_CLIENT,
AUTH_OIDC_SECRET: process.env.AUTH_OIDC_SECRET,
AUTH_OIDC_ISSUER_URL: process.env.AUTH_OIDC_ISSUER_URL,
AUTH_OIDC_HOST: process.env.AUTH_OIDC_HOST,
AUTH_OIDC_SCOPES: process.env.AUTH_OIDC_SCOPES,
AUTH_OIDC_DISCOVERY_ENDPOINT: process.env.AUTH_OIDC_DISCOVERY_ENDPOINT,
AUTH_OIDC_JWKS_ENDPOINT: process.env.AUTH_OIDC_JWKS_ENDPOINT,
AUTH_OIDC_PKCE: process.env.AUTH_OIDC_PKCE,
ALLOWED_GROUP: process.env.ALLOWED_GROUP,
AUTH_OIDC_ID: process.env.AUTH_OIDC_ID, AUTH_EMAIL_PASSWORD_ENABLED: process.env.AUTH_EMAIL_PASSWORD_ENABLED,
AUTH_OIDC_TITLE: process.env.AUTH_OIDC_TITLE, AUTH_SIGNUP_ENABLED: process.env.AUTH_SIGNUP_ENABLED,
AUTH_OIDC_DESC: process.env.AUTH_OIDC_DESC, AUTH_PASSKEY_ENABLED: process.env.AUTH_PASSKEY_ENABLED,
AUTH_OIDC_ICON: process.env.AUTH_OIDC_ICON,
AUTH_OIDC_CLIENT: process.env.AUTH_OIDC_CLIENT,
AUTH_OIDC_SECRET: process.env.AUTH_OIDC_SECRET,
AUTH_OIDC_ISSUER_URL: process.env.AUTH_OIDC_ISSUER_URL,
AUTH_OIDC_HOST: process.env.AUTH_OIDC_HOST,
AUTH_OIDC_SCOPES: process.env.AUTH_OIDC_SCOPES,
AUTH_OIDC_DISCOVERY_ENDPOINT: process.env.AUTH_OIDC_DISCOVERY_ENDPOINT,
AUTH_OIDC_JWKS_ENDPOINT: process.env.AUTH_OIDC_JWKS_ENDPOINT,
AUTH_OIDC_PKCE: process.env.AUTH_OIDC_PKCE,
ALLOWED_GROUP: process.env.ALLOWED_GROUP,
AUTH_EMAIL_PASSWORD_ENABLED: process.env.AUTH_EMAIL_PASSWORD_ENABLED, PRIVATE_PATH:
AUTH_SIGNUP_ENABLED: process.env.AUTH_SIGNUP_ENABLED, process.env.PRIVATE_PATH || path.join(process.cwd(), "private"),
AUTH_PASSKEY_ENABLED: process.env.AUTH_PASSKEY_ENABLED, },
PRIVATE_PATH: process.env.PRIVATE_PATH || path.join(process.cwd(), 'private')
},
}); });
+654 -512
View File
File diff suppressed because it is too large Load Diff
+69 -56
View File
@@ -1,63 +1,76 @@
import { env } from "@/env.mjs"; import { env } from "@/env.mjs";
import { getOidcProviders } from "./oidc";
export interface AuthProviderConfig { export interface AuthProviderConfig {
id: string; id: string;
isActive: boolean; isActive: boolean;
name?: string; name?: string;
icon: string; icon: string;
isManual?: boolean; isManual?: boolean;
title?: string; title?: string;
description?: string; description?: string;
type: "social" | "sso" | "credential" | "passkey"; type: "social" | "sso" | "credential" | "passkey";
allowLinking?: boolean;
allowUnlinking?: boolean;
} }
const oidcProviders = getOidcProviders();
export const SUPPORTED_PROVIDERS: AuthProviderConfig[] = [ export const SUPPORTED_PROVIDERS: AuthProviderConfig[] = [
{ {
id: "credential", id: "credential",
isActive: env.AUTH_EMAIL_PASSWORD_ENABLED === "true", isActive: env.AUTH_EMAIL_PASSWORD_ENABLED === "true",
name: "Password", name: "Password",
icon: "lucide:lock", icon: "lucide:lock",
title: "Password", title: "Password",
description: "Standard email and password login.", description: "Standard email and password login.",
isManual: true, isManual: true,
type: "credential" type: "credential",
}, allowLinking: true,
{ allowUnlinking: true,
id: "google", },
isActive: !!env.AUTH_GOOGLE_ID, {
name: "Google", id: "google",
icon: "logos:google-icon", isActive: !!env.AUTH_GOOGLE_ID,
title: "Google", name: "Google",
description: "Sign in with your Google account.", icon: "logos:google-icon",
type: "social" title: "Google",
}, description: "Sign in with your Google account.",
{ type: "social",
id: "github", allowLinking: true,
isActive: !!env.AUTH_GITHUB_ID, allowUnlinking: true,
name: "GitHub", },
icon: "logos:github-icon", {
title: "GitHub", id: "github",
description: "Sign in with your GitHub account.", isActive: !!env.AUTH_GITHUB_ID,
type: "social" name: "GitHub",
}, icon: "logos:github-icon",
{ title: "GitHub",
id: env.AUTH_OIDC_ID || "oidc", description: "Sign in with your GitHub account.",
isActive: !!env.AUTH_OIDC_CLIENT, type: "social",
name: env.AUTH_OIDC_TITLE || "SSO", allowLinking: true,
icon: env.AUTH_OIDC_ICON || "lucide:building", allowUnlinking: true,
title: env.AUTH_OIDC_TITLE || "SSO", },
description: env.AUTH_OIDC_DESC || "Sign in with your SSO account.", ...oidcProviders.map((p) => ({
isManual: true, id: p.id,
type: "sso" isActive: true,
}, name: p.title,
{ icon: p.icon,
id: "passkey", title: p.title,
isActive: env.AUTH_PASSKEY_ENABLED === "true", description: p.description,
name: "Passkey", isManual: true,
icon: "lucide:fingerprint", type: "sso" as const,
title: "Passkey", allowLinking: p.allowLinking,
description: "Sign in with your passkey.", allowUnlinking: p.allowUnlinking,
isManual: false, })),
type: "passkey" {
} id: "passkey",
isActive: env.AUTH_PASSKEY_ENABLED === "true",
name: "Passkey",
icon: "lucide:fingerprint",
title: "Passkey",
description: "Sign in with your passkey.",
isManual: false,
type: "passkey",
},
]; ];
+93
View File
@@ -0,0 +1,93 @@
import { env } from "@/env.mjs";
export interface OIDCProvider {
id: string;
title: string;
description: string;
icon: string;
client: string;
secret: string;
issuerUrl: string;
host: string;
scopes?: string;
discoveryEndpoint?: string;
jwksEndpoint?: string;
pkce: boolean;
allowedGroup?: string;
roleMap?: string;
defaultRole?: string;
allowLinking: boolean;
allowUnlinking: boolean;
}
export function getOidcProviders(): OIDCProvider[] {
const providers: OIDCProvider[] = [];
if (
env.AUTH_OIDC_CLIENT &&
(env.AUTH_OIDC_ISSUER_URL || env.AUTH_OIDC_DISCOVERY_ENDPOINT)
) {
providers.push({
id: env.AUTH_OIDC_ID || "oidc",
title: env.AUTH_OIDC_TITLE || "SSO",
description: env.AUTH_OIDC_DESC || "Sign in with your SSO account.",
icon: env.AUTH_OIDC_ICON || "lucide:building",
client: env.AUTH_OIDC_CLIENT,
secret: env.AUTH_OIDC_SECRET || "",
issuerUrl: env.AUTH_OIDC_ISSUER_URL || "",
host: env.AUTH_OIDC_HOST || "",
scopes: env.AUTH_OIDC_SCOPES,
discoveryEndpoint: env.AUTH_OIDC_DISCOVERY_ENDPOINT,
jwksEndpoint: env.AUTH_OIDC_JWKS_ENDPOINT,
pkce: env.AUTH_OIDC_PKCE === "true",
allowedGroup: env.ALLOWED_GROUP,
roleMap: process.env.AUTH_OIDC_ROLE_MAP,
defaultRole: process.env.AUTH_OIDC_DEFAULT_ROLE,
allowLinking: process.env.AUTH_OIDC_ALLOW_LINKING !== "false",
allowUnlinking: process.env.AUTH_OIDC_ALLOW_UNLINKING !== "false",
});
}
const prefixes = new Set<string>();
Object.keys(process.env).forEach((key) => {
const match = key.match(/^AUTH_OIDC_(.+)_CLIENT$/);
if (match) {
prefixes.add(match[1]);
}
});
prefixes.forEach((prefix) => {
const client = process.env[`AUTH_OIDC_${prefix}_CLIENT`];
const issuer = process.env[`AUTH_OIDC_${prefix}_ISSUER_URL`];
const discovery = process.env[`AUTH_OIDC_${prefix}_DISCOVERY_ENDPOINT`];
if (!client || (!issuer && !discovery)) return;
providers.push({
id: process.env[`AUTH_OIDC_${prefix}_ID`] || prefix.toLowerCase(),
title: process.env[`AUTH_OIDC_${prefix}_TITLE`] || prefix,
description:
process.env[`AUTH_OIDC_${prefix}_DESC`] || `Sign in with ${prefix}`,
icon: process.env[`AUTH_OIDC_${prefix}_ICON`] || "lucide:building",
client: client,
secret: process.env[`AUTH_OIDC_${prefix}_SECRET`] || "",
issuerUrl: issuer || "",
host: process.env[`AUTH_OIDC_${prefix}_HOST`] || "",
scopes: process.env[`AUTH_OIDC_${prefix}_SCOPES`],
discoveryEndpoint: discovery,
jwksEndpoint: process.env[`AUTH_OIDC_${prefix}_JWKS_ENDPOINT`],
pkce: process.env[`AUTH_OIDC_${prefix}_PKCE`] === "true",
allowedGroup:
process.env[`AUTH_OIDC_${prefix}_ALLOWED_GROUP`] ||
process.env.ALLOWED_GROUP,
roleMap: process.env[`AUTH_OIDC_${prefix}_ROLE_MAP`],
defaultRole: process.env[`AUTH_OIDC_${prefix}_DEFAULT_ROLE`],
allowLinking:
process.env[`AUTH_OIDC_${prefix}_ALLOW_LINKING`] !== "false",
allowUnlinking:
process.env[`AUTH_OIDC_${prefix}_ALLOW_UNLINKING`] !== "false",
});
});
return providers;
}
-6
View File
@@ -1,6 +0,0 @@
EDGE_KEY="eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODcvIiwiYWdlbnRJZCI6IjAwNWVjODdiLWY1MzAtNDU5YS04MjM4LTgzZjdiNzZiYjEzOSIsIm1hc3RlcktleUI2NCI6Imt0cC9ZbXk3TE1lU2hkNEJHZ3I5RDBIeE5sYnozRExVUnRoVlYwQW8vek09In0="
PROJECT_NAME="test-dev"
DB_PG_2CD9_PORT="42597"
DB_PG_2CD9_DB="pg_d7699b61"
DB_PG_2CD9_USER="admin"
DB_PG_2CD9_PASS="9e20bcd7e4e21cef"
-14
View File
@@ -1,14 +0,0 @@
{
"databases": [
{
"name": "pg_d7699b61",
"database": "pg_d7699b61",
"type": "postgresql",
"username": "admin",
"password": "9e20bcd7e4e21cef",
"port": 42597,
"host": "localhost",
"generated_id": "b8edfd1e-00a6-412f-988d-62b477966578"
}
]
}
-42
View File
@@ -1,42 +0,0 @@
name: test-dev
services:
app:
container_name: test-dev-app
restart: always
image: portabase/agent:latest
volumes:
- ./databases.json:/config/config.json
extra_hosts:
- "localhost:host-gateway"
environment:
TZ: "Europe/Paris"
EDGE_KEY: "${EDGE_KEY}"
LOG: info
networks:
- portabase
db-pg-2cd9:
container_name: test-dev-db-pg-2cd9
image: postgres:17-alpine
networks:
- portabase
- default
ports:
- "${DB_PG_2CD9_PORT}:5432"
volumes:
- db-pg-2cd9-data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=${DB_PG_2CD9_DB}
- POSTGRES_USER=${DB_PG_2CD9_USER}
- POSTGRES_PASSWORD=${DB_PG_2CD9_PASS}
volumes:
db-pg-2cd9-data:
networks:
portabase:
name: portabase_network
external: true