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,5 +1,10 @@
import { PageParams } from "@/types/next"; import { PageParams } from "@/types/next";
import {Page, PageContent, PageDescription, PageTitle} from "@/features/layout/page"; import {
Page,
PageContent,
PageDescription,
PageTitle,
} from "@/features/layout/page";
import { db } from "@/db"; import { db } from "@/db";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
@@ -12,23 +17,26 @@ import {AgentContentPage} from "@/components/wrappers/dashboard/agent/agent-cont
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({ const agent = await db.query.agent.findFirst({
where: eq(drizzleDb.schemas.agent.id, agentId), where: eq(drizzleDb.schemas.agent.id, agentId),
with: { with: {
databases: true databases: true,
} },
}) });
if (!agent) { if (!agent) {
notFound() notFound();
} }
const edgeKey = await generateEdgeKey(getServerUrl(), agent.id); 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">
@@ -38,7 +46,10 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
</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
agent={agent as AgentType & { id: string }}
typeTrigger={"edit"}
/>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"} /> <ButtonDeleteAgent agentId={agentId} text={"Delete Agent"} />
@@ -48,14 +59,13 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
</div> </div>
{agent.description && ( {agent.description && (
<PageDescription className="mt-5 sm:mt-0">{agent.description}</PageDescription> <PageDescription className="mt-5 sm:mt-0">
{agent.description}
</PageDescription>
)} )}
<PageContent className="flex flex-col w-full h-full justify-between gap-6"> <PageContent className="flex flex-col w-full h-full justify-between gap-6">
<AgentContentPage <AgentContentPage agent={agent} edgeKey={edgeKey} />
agent={agent}
edgeKey={edgeKey}
/>
</PageContent> </PageContent>
</Page> </Page>
) );
} }
+17 -1
View File
@@ -41,7 +41,23 @@ services:
- "8080:8080" - "8080:8080"
volumes: volumes:
- keycloak-data:/opt/keycloak/data - 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,15 +1,26 @@
"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 {
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form"; 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, BackupActionsSchema,
BackupActionsType 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";
@@ -17,11 +28,16 @@ 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 {
getStatusColor,
getStatusIcon,
} from "@/components/wrappers/dashboard/admin/notifications/logs/columns";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
createRestorationBackupAction, deleteBackupAction, deleteBackupStorageAction, createRestorationBackupAction,
downloadBackupAction 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";
@@ -33,11 +49,14 @@ 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,
const filteredBackupStorages = backup.storages?.filter((storage) => storage.deletedAt === null) ?? [] action,
}: BackupActionsFormProps) => {
const filteredBackupStorages =
backup.storages?.filter((storage) => storage.deletedAt === null) ?? [];
const { closeModal } = useBackupModal(); const { closeModal } = useBackupModal();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const router = useRouter(); const router = useRouter();
@@ -48,52 +67,67 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async (values: BackupActionsType) => { mutationFn: async (values: BackupActionsType) => {
let result:
let result: SafeActionResult<string, ZodString, readonly [], { | SafeActionResult<
string,
ZodString,
readonly [],
{
_errors?: string[] | undefined; _errors?: string[] | undefined;
}, readonly [], ServerActionResult<string | Restoration | Backup>, object> | undefined },
readonly [],
ServerActionResult<string | Restoration | Backup>,
object
>
| undefined;
if (action === "download") { if (action === "download") {
result = await downloadBackupAction({backupStorageId: values.backupStorageId}) result = await downloadBackupAction({
backupStorageId: values.backupStorageId,
});
} else if (action === "restore") { } else if (action === "restore") {
result = await createRestorationBackupAction({ result = await createRestorationBackupAction({
databaseId: backup.databaseId, databaseId: backup.databaseId,
backupStorageId: values.backupStorageId, backupStorageId: values.backupStorageId,
backupId: backup.id backupId: backup.id,
}) });
} else if (action === "delete") { } else if (action === "delete") {
result = await deleteBackupStorageAction({ result = await deleteBackupStorageAction({
databaseId: backup.databaseId, databaseId: backup.databaseId,
backupStorageId: values.backupStorageId, backupStorageId: values.backupStorageId,
backupId: backup.id, backupId: backup.id,
}) });
} }
const inner = result?.data; const inner = result?.data;
if (inner?.success) { if (inner?.success) {
toast.success(inner.actionSuccess?.message); toast.success(inner.actionSuccess?.message);
queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]}); queryClient.invalidateQueries({
queryKey: ["database-data", backup.databaseId],
});
router.refresh(); router.refresh();
if (action === "download") { if (action === "download") {
const url = inner.value const url = inner.value;
if (typeof url === "string") { if (typeof url === "string") {
window.open(url, "_self"); window.open(url, "_self");
} }
closeModal() closeModal();
} else if (action === "restore") { } else if (action === "restore") {
closeModal() closeModal();
} else if (action === "delete") { } else if (action === "delete") {
closeModal() closeModal();
} else { } else {
closeModal() closeModal();
} }
} else { } else {
if (action === "delete") { if (action === "delete") {
toast.success("Backup deleted successfully.") toast.success("Backup deleted successfully.");
queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]}); queryClient.invalidateQueries({
queryKey: ["database-data", backup.databaseId],
});
router.refresh(); router.refresh();
closeModal() closeModal();
} else { } else {
toast.error(inner?.actionError?.message ?? "An error occurred."); toast.error(inner?.actionError?.message ?? "An error occurred.");
} }
@@ -103,19 +137,20 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
const mutationDeleteEntireBackup = useMutation({ const mutationDeleteEntireBackup = useMutation({
mutationFn: async () => { mutationFn: async () => {
const result = await deleteBackupAction({ const result = await deleteBackupAction({
databaseId: backup.databaseId, databaseId: backup.databaseId,
backupId: backup.id, backupId: backup.id,
}) });
const inner = result?.data; const inner = result?.data;
if (inner?.success) { if (inner?.success) {
toast.success(inner.actionSuccess?.message); toast.success(inner.actionSuccess?.message);
queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]}); queryClient.invalidateQueries({
queryKey: ["database-data", backup.databaseId],
});
router.refresh(); router.refresh();
closeModal() closeModal();
} else { } else {
toast.error(inner?.actionError?.message); toast.error(inner?.actionError?.message);
} }
@@ -124,8 +159,6 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
return ( return (
<TooltipProvider> <TooltipProvider>
<Form <Form
form={form} form={form}
className="flex flex-col gap-4 mb-1" className="flex flex-col gap-4 mb-1"
@@ -133,8 +166,7 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
await mutation.mutateAsync(values); await mutation.mutateAsync(values);
}} }}
> >
{filteredBackupStorages.length > 0 ? (
{filteredBackupStorages.length > 0 ?
<FormField <FormField
control={form.control} control={form.control}
name="backupStorageId" name="backupStorageId"
@@ -142,7 +174,6 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
<FormItem> <FormItem>
<FormLabel>Choose a storage backup</FormLabel> <FormLabel>Choose a storage backup</FormLabel>
<FormControl> <FormControl>
<div style={{ height: "250px" }}> <div style={{ height: "250px" }}>
<Swiper <Swiper
direction="vertical" direction="vertical"
@@ -154,47 +185,73 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
className="mySwiper" className="mySwiper"
style={{ height: "100%" }} style={{ height: "100%" }}
> >
{filteredBackupStorages.map((storage: BackupStorageWith) => ( {filteredBackupStorages.map(
(storage: BackupStorageWith) => (
<SwiperSlide key={storage.id}> <SwiperSlide key={storage.id}>
<button <button
disabled={action !== "delete" && storage.status.toLowerCase() !== "success"} disabled={
action !== "delete" &&
storage.status.toLowerCase() !== "success"
}
type="button" type="button"
onClick={() => field.onChange(storage.id)} onClick={() => field.onChange(storage.id)}
className={`w-full h-full flex items-start gap-3 p-4 rounded-lg border text-left transition-colors className={`w-full h-full flex items-start gap-3 p-4 rounded-lg border text-left transition-colors
${field.value === storage.id ${
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
? "border-foreground"
: "border-muted-foreground"
} flex items-center justify-center`} } flex items-center justify-center`}
> >
{field.value === storage.id && {field.value === storage.id && (
<div className="h-2 w-2 rounded-full bg-foreground"/>} <div className="h-2 w-2 rounded-full bg-foreground" />
)}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex-1 min-w-0 flex flex-col gap-1"> <div className="flex-1 min-w-0 flex flex-col gap-1">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2 min-w-0 w-full">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0 flex-1">
<div className="shrink-0"> <div className="shrink-0">
{getChannelIcon(storage.storageChannel?.provider || "")} {getChannelIcon(
storage.storageChannel?.provider ||
"",
)}
</div> </div>
<h3 className="font-medium text-foreground truncate"> <h3 className="font-medium text-foreground truncate min-w-0">
{storage.storageChannel?.name} {storage.storageChannel?.name}
</h3> </h3>
<Badge variant="secondary" <Badge
className="text-xs font-mono shrink-0"> variant="secondary"
className="text-xs font-mono shrink-0"
>
{storage.storageChannel?.provider} {storage.storageChannel?.provider}
</Badge> </Badge>
</div> </div>
<Badge variant="outline" <Badge
className={`gap-1.5 shrink-0 ${getStatusColor(storage.status)}`}> variant="outline"
{getStatusIcon(storage.status === "success")} className={`gap-1.5 shrink-0 ${getStatusColor(storage.status)}`}
<span >
className="capitalize">{storage.status.toUpperCase()}</span> {getStatusIcon(
storage.status === "success",
)}
<span className="capitalize">
{storage.status.toUpperCase()}
</span>
</Badge> </Badge>
</div> </div>
</div> </div>
@@ -202,7 +259,8 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
</div> </div>
</button> </button>
</SwiperSlide> </SwiperSlide>
)) ?? <p>No storages available</p>} ),
) ?? <p>No storages available</p>}
</Swiper> </Swiper>
</div> </div>
</FormControl> </FormControl>
@@ -210,17 +268,18 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
</FormItem> </FormItem>
)} )}
/> />
) : (
:
<Alert> <Alert>
<AlertCircleIcon /> <AlertCircleIcon />
<AlertTitle>Backup does not have files</AlertTitle> <AlertTitle>Backup does not have files</AlertTitle>
<AlertDescription> <AlertDescription>
<p>You can safely delete the entire backup; no files seem to be related. Maybe an error <p>
occurred.</p> You can safely delete the entire backup; no files seem to be
related. Maybe an error occurred.
</p>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
} )}
<div className="flex flex-row items-center gap-x-4 w-full"> <div className="flex flex-row items-center gap-x-4 w-full">
{action === "delete" && ( {action === "delete" && (
@@ -244,10 +303,9 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
> >
Confirm Confirm
</ButtonWithLoading> </ButtonWithLoading>
)} )}
</div> </div>
</Form> </Form>
</TooltipProvider> </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,10 +29,21 @@ 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;
@@ -29,9 +52,18 @@ interface ProfileSecurityProps {
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({
user,
sessions,
credentialAccount,
currentSession,
isPasswordEnabled = false,
isPasskeyEnabled = false,
providers,
}: ProfileSecurityProps) {
const router = useRouter(); const router = useRouter();
const [isBackupCodesDialogOpen, setIsBackupCodesDialogOpen] = useState(false); const [isBackupCodesDialogOpen, setIsBackupCodesDialogOpen] = useState(false);
@@ -122,8 +154,12 @@ export function ProfileSecurity({ user, sessions, credentialAccount, currentSess
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>
<div className="space-y-6"> <div className="space-y-6">
@@ -135,13 +171,21 @@ export function ProfileSecurity({ user, sessions, credentialAccount, currentSess
<div className="space-y-1"> <div className="space-y-1">
<div className="font-medium">Password</div> <div className="font-medium">Password</div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{user.lastChangedPasswordAt ? `Last changed ${timeAgo(new Date(user.lastChangedPasswordAt))}` : "Never changed"} {user.lastChangedPasswordAt
? `Last changed ${timeAgo(new Date(user.lastChangedPasswordAt))}`
: "Never changed"}
</div> </div>
</div> </div>
{credentialAccount ? ( {credentialAccount ? (
<ResetPasswordProfileProviderModal open={isPasswordDialogOpen} onOpenChange={setIsPasswordDialogOpen} /> <ResetPasswordProfileProviderModal
open={isPasswordDialogOpen}
onOpenChange={setIsPasswordDialogOpen}
/>
) : ( ) : (
<SetPasswordProfileProviderModal open={isPasswordDialogOpen} onOpenChange={setIsPasswordDialogOpen} /> <SetPasswordProfileProviderModal
open={isPasswordDialogOpen}
onOpenChange={setIsPasswordDialogOpen}
/>
)} )}
</div> </div>
@@ -154,23 +198,37 @@ export function ProfileSecurity({ user, sessions, credentialAccount, currentSess
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="font-medium">Two-Factor Authentication</div> <div className="font-medium">Two-Factor Authentication</div>
{user.twoFactorEnabled && ( {user.twoFactorEnabled && (
<Badge variant="secondary" className="text-[10px] h-5 px-1.5 text-green-600 bg-green-500/10 border-0"> <Badge
variant="secondary"
className="text-[10px] h-5 px-1.5 text-green-600 bg-green-500/10 border-0"
>
Active Active
</Badge> </Badge>
)} )}
</div> </div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Enhance the security of your account by requiring a second form of verification during login. Enhance the security of your account by requiring a second form
of verification during login.
</div> </div>
</div> </div>
{user.twoFactorEnabled ? ( {user.twoFactorEnabled ? (
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<ViewBackupCodesModal open={isBackupCodesDialogOpen} onOpenChange={setIsBackupCodesDialogOpen} /> <ViewBackupCodesModal
<Disable2FAProfileProviderModal open={isDisable2FADialogOpen} onOpenChange={setIsDisable2FADialogOpen} /> open={isBackupCodesDialogOpen}
onOpenChange={setIsBackupCodesDialogOpen}
/>
<Disable2FAProfileProviderModal
open={isDisable2FADialogOpen}
onOpenChange={setIsDisable2FADialogOpen}
/>
</div> </div>
) : ( ) : (
<Setup2FAProfileProviderModal disabled={!credentialAccount} open={isSetup2FADialogOpen} onOpenChange={setIsSetup2FADialogOpen} /> <Setup2FAProfileProviderModal
disabled={!credentialAccount}
open={isSetup2FADialogOpen}
onOpenChange={setIsSetup2FADialogOpen}
/>
)} )}
</div> </div>
</div> </div>
@@ -181,7 +239,10 @@ export function ProfileSecurity({ user, sessions, credentialAccount, currentSess
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-lg font-medium">Passkeys</h3> <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 className="text-sm text-muted-foreground">
Login securely with your fingerprint, face recognition, or
hardware key.
</div>
</div> </div>
<Dialog open={isAddPasskeyOpen} onOpenChange={setIsAddPasskeyOpen}> <Dialog open={isAddPasskeyOpen} onOpenChange={setIsAddPasskeyOpen}>
@@ -194,7 +255,9 @@ export function ProfileSecurity({ user, sessions, credentialAccount, currentSess
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Add New Passkey</DialogTitle> <DialogTitle>Add New Passkey</DialogTitle>
<DialogDescription>Create a name for your passkey to identify it later.</DialogDescription> <DialogDescription>
Create a name for your passkey to identify it later.
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2"> <div className="grid gap-2">
@@ -208,11 +271,19 @@ export function ProfileSecurity({ user, sessions, credentialAccount, currentSess
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setIsAddPasskeyOpen(false)}> <Button
variant="outline"
onClick={() => setIsAddPasskeyOpen(false)}
>
Cancel Cancel
</Button> </Button>
<Button onClick={() => addPasskey()} disabled={isAddingPasskey}> <Button
{isAddingPasskey && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} onClick={() => addPasskey()}
disabled={isAddingPasskey}
>
{isAddingPasskey && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Create Passkey Create Passkey
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -226,15 +297,24 @@ export function ProfileSecurity({ user, sessions, credentialAccount, currentSess
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div> </div>
) : passkeys && passkeys.length > 0 ? ( ) : passkeys && passkeys.length > 0 ? (
passkeys.map((pk: any) => <PasskeyRow key={pk.id} passkey={pk} onRevoke={(id) => revokePasskey(id)} isRevoking={isRevokingPasskey} />) 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 className="p-4 text-center text-muted-foreground">
No passkeys found.
</div>
)} )}
</div> </div>
</div> </div>
)} )}
<div className="space-y-6"> <div className="space-y-6 pb-10">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-lg font-medium">Active Sessions</h3> <h3 className="text-lg font-medium">Active Sessions</h3>
{sessions && sessions.length > 1 && ( {sessions && sessions.length > 1 && (
@@ -245,7 +325,9 @@ export function ProfileSecurity({ user, sessions, credentialAccount, currentSess
onClick={() => revokeOthers()} onClick={() => revokeOthers()}
disabled={isRevokingOthers || (sessions?.length || 0) <= 1} disabled={isRevokingOthers || (sessions?.length || 0) <= 1}
> >
{isRevokingOthers && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {isRevokingOthers && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Revoke All Revoke All
</Button> </Button>
)} )}
@@ -259,10 +341,13 @@ export function ProfileSecurity({ user, sessions, credentialAccount, currentSess
onRevoke={(token) => revokeSession(token)} onRevoke={(token) => revokeSession(token)}
isRevoking={isRevoking} isRevoking={isRevoking}
currentSession={currentSession} currentSession={currentSession}
providers={providers}
/> />
)) ))
) : ( ) : (
<div className="p-4 text-center text-muted-foreground">No active sessions found.</div> <div className="p-4 text-center text-muted-foreground">
No active sessions found.
</div>
)} )}
</div> </div>
</div> </div>
@@ -275,23 +360,50 @@ function SessionRow({
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" />
{provider && (
<div className="absolute -bottom-1 -right-1 w-5 h-5 rounded-full bg-background border flex items-center justify-center overflow-hidden">
{provider.icon.startsWith("/") || provider.icon.startsWith("http") ? (
<Image
src={provider.icon}
alt={provider.id}
width={12}
height={12}
className="w-3 h-3"
unoptimized={provider.icon.startsWith("http")}
/>
) : (
<Icon icon={provider.icon} className="w-3 h-3" />
)}
</div>
)}
</div> </div>
<div className="space-y-0.5"> <div className="space-y-0.5">
<div className="text-sm font-medium flex items-center gap-2"> <div className="text-sm font-medium flex items-center gap-2">
{deviceInfo.os} <span className="text-muted-foreground font-normal"> {deviceInfo.browser}</span> {deviceInfo.os}{" "}
<span className="text-muted-foreground font-normal">
{deviceInfo.browser}
</span>
{provider && (
<span className="text-muted-foreground font-normal">
{provider.title || provider.name}
</span>
)}
{session.id === currentSession.id && ( {session.id === currentSession.id && (
<Badge <Badge
variant="outline" variant="outline"
@@ -303,7 +415,11 @@ function SessionRow({
</div> </div>
<div className="text-xs text-muted-foreground flex items-center gap-1"> <div className="text-xs text-muted-foreground flex items-center gap-1">
<Globe className="w-3 h-3" /> {session.ipAddress} <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> <span className="ml-1">
{session.id === currentSession.id
? "Active now"
: `Last active ${timeAgo(new Date(session.createdAt))}`}
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -316,7 +432,11 @@ function SessionRow({
onClick={() => onRevoke(session.token)} onClick={() => onRevoke(session.token)}
disabled={isRevoking} disabled={isRevoking}
> >
{isRevoking ? <Loader2 className="h-4 w-4 animate-spin" /> : <LogOut className="w-4 h-4" />} {isRevoking ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<LogOut className="w-4 h-4" />
)}
<span className="sr-only">Revoke</span> <span className="sr-only">Revoke</span>
</Button> </Button>
)} )}
@@ -324,7 +444,15 @@ function SessionRow({
); );
} }
function PasskeyRow({ passkey, onRevoke, isRevoking }: { passkey: any; onRevoke: (id: string) => void; isRevoking: boolean }) { function PasskeyRow({
passkey,
onRevoke,
isRevoking,
}: {
passkey: any;
onRevoke: (id: string) => void;
isRevoking: boolean;
}) {
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">
@@ -332,8 +460,12 @@ function PasskeyRow({ passkey, onRevoke, isRevoking }: { passkey: any; onRevoke:
<Fingerprint className="w-5 h-5" /> <Fingerprint className="w-5 h-5" />
</div> </div>
<div className="space-y-0.5"> <div className="space-y-0.5">
<div className="font-medium text-sm">{passkey.name || "Unnamed Passkey"}</div> <div className="font-medium text-sm">
<div className="text-xs text-muted-foreground">Created {timeAgo(new Date(passkey.createdAt))}</div> {passkey.name || "Unnamed Passkey"}
</div>
<div className="text-xs text-muted-foreground">
Created {timeAgo(new Date(passkey.createdAt))}
</div>
</div> </div>
</div> </div>
<Button <Button
@@ -343,7 +475,11 @@ function PasskeyRow({ passkey, onRevoke, isRevoking }: { passkey: any; onRevoke:
onClick={() => onRevoke(passkey.id)} onClick={() => onRevoke(passkey.id)}
disabled={isRevoking} disabled={isRevoking}
> >
{isRevoking ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="w-4 h-4" />} {isRevoking ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
<span className="sr-only">Revoke</span> <span className="sr-only">Revoke</span>
</Button> </Button>
</div> </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", {
+11 -5
View File
@@ -1,7 +1,7 @@
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;
@@ -15,7 +15,9 @@ export const env = createEnv({
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
.string()
.regex(/^https?:\/\//, "URL must start with http:// or https://"),
PROJECT_SECRET: z.string(), PROJECT_SECRET: z.string(),
SMTP_PASSWORD: z.string().optional(), SMTP_PASSWORD: z.string().optional(),
@@ -32,7 +34,11 @@ export const env = createEnv({
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(),
@@ -53,7 +59,6 @@ export const env = createEnv({
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: { client: {
NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(), NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(),
@@ -102,6 +107,7 @@ export const env = createEnv({
AUTH_SIGNUP_ENABLED: process.env.AUTH_SIGNUP_ENABLED, AUTH_SIGNUP_ENABLED: process.env.AUTH_SIGNUP_ENABLED,
AUTH_PASSKEY_ENABLED: process.env.AUTH_PASSKEY_ENABLED, AUTH_PASSKEY_ENABLED: process.env.AUTH_PASSKEY_ENABLED,
PRIVATE_PATH: process.env.PRIVATE_PATH || path.join(process.cwd(), 'private') PRIVATE_PATH:
process.env.PRIVATE_PATH || path.join(process.cwd(), "private"),
}, },
}); });
+220 -78
View File
@@ -4,11 +4,29 @@ import * as drizzleDb from "@/db";
import { db } from "@/db"; import { db } from "@/db";
import { env } from "@/env.mjs"; import { env } from "@/env.mjs";
import { nextCookies } from "better-auth/next-js"; import { nextCookies } from "better-auth/next-js";
import {admin as adminPlugin, openAPI, Organization, organization, twoFactor} from "better-auth/plugins"; import {
import {ac, admin, orgAdmin, orgMember, orgOwner, pending, superadmin, user} from "@/lib/auth/permissions"; admin as adminPlugin,
openAPI,
Organization,
organization,
twoFactor,
} from "better-auth/plugins";
import {
ac,
admin,
orgAdmin,
orgMember,
orgOwner,
pending,
superadmin,
user,
} from "@/lib/auth/permissions";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { count, eq } from "drizzle-orm"; 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"; import { sendEmail } from "@/lib/email";
import { render } from "@react-email/render"; import { render } from "@react-email/render";
import { withUpdatedAt } from "@/db/utils"; import { withUpdatedAt } from "@/db/utils";
@@ -19,6 +37,10 @@ import EmailNewLogin from "@/components/emails/auth/email-new-login";
import { sso } from "@better-auth/sso"; import { sso } from "@better-auth/sso";
import { AuthProviderConfig, SUPPORTED_PROVIDERS } from "@/lib/auth/config"; import { AuthProviderConfig, SUPPORTED_PROVIDERS } from "@/lib/auth/config";
import { passkey } from "@better-auth/passkey"; import { passkey } from "@better-auth/passkey";
import { getOidcProviders } from "./oidc";
import { APIError } from "better-auth/api";
const oidcProviders = getOidcProviders();
export const auth = betterAuth({ export const auth = betterAuth({
database: drizzleAdapter(db, { database: drizzleAdapter(db, {
@@ -32,10 +54,15 @@ export const auth = betterAuth({
enabled: env.AUTH_EMAIL_PASSWORD_ENABLED === "true", enabled: env.AUTH_EMAIL_PASSWORD_ENABLED === "true",
requireEmailVerification: false, requireEmailVerification: false,
sendResetPassword: async ({ user, token }, request) => { sendResetPassword: async ({ user, token }, request) => {
await db
await db.update(drizzleDb.schemas.user).set(withUpdatedAt({ .update(drizzleDb.schemas.user)
.set(
withUpdatedAt({
emailVerified: true, emailVerified: true,
})).where(eq(drizzleDb.schemas.user.id, user.id)).returning(); }),
)
.where(eq(drizzleDb.schemas.user.id, user.id))
.returning();
await sendEmail({ await sendEmail({
to: user.email, to: user.email,
@@ -45,22 +72,22 @@ export const auth = betterAuth({
firstname: user.name!, firstname: user.name!,
token, token,
}), }),
{} {},
), ),
}); });
} },
}, },
emailVerification: { emailVerification: {
async sendVerificationEmail({ user, token, url }) { async sendVerificationEmail({ user, token, url }) {
await sendEmail({ await sendEmail({
to: user.email, to: user.email,
subject: "Portabase Email Verification", subject: "Portabase Email Verification",
html: await render(EmailVerification({ html: await render(
EmailVerification({
firstname: user.name, firstname: user.name,
url: url url: url,
})), }),
),
}); });
await ( await (
@@ -77,10 +104,11 @@ export const auth = betterAuth({
}); });
}, },
}, },
socialProviders: SUPPORTED_PROVIDERS.reduce((acc: any, provider: AuthProviderConfig) => { socialProviders: SUPPORTED_PROVIDERS.reduce(
(acc: any, provider: AuthProviderConfig) => {
if (!provider.isActive) return acc; if (!provider.isActive) return acc;
if (provider.id === "credential") return acc; if (provider.id === "credential") return acc;
if (provider.id === env.AUTH_OIDC_ID!) return acc; if (provider.type === "sso") return acc;
if (provider.id === "google") { if (provider.id === "google") {
acc.google = { acc.google = {
clientId: env.AUTH_GOOGLE_ID! as string, clientId: env.AUTH_GOOGLE_ID! as string,
@@ -94,73 +122,137 @@ export const auth = betterAuth({
}; };
} }
return acc; return acc;
}, {}), },
{},
),
account: { account: {
accountLinking: { accountLinking: {
enabled: true, enabled: true,
trustedProviders: ["google", "github", "credential",env.AUTH_OIDC_ID!], trustedProviders: [
allowDifferentEmails: false "google",
"github",
"credential",
...oidcProviders.map((p) => p.id),
],
allowDifferentEmails: false,
}, },
}, },
plugins: [ plugins: [
sso({ sso({
defaultSSO: [{ defaultSSO: oidcProviders.map((p) => ({
oidcConfig: { oidcConfig: {
issuer: env.AUTH_OIDC_ISSUER_URL!, issuer: p.issuerUrl,
discoveryEndpoint: env.AUTH_OIDC_DISCOVERY_ENDPOINT!, discoveryEndpoint: p.discoveryEndpoint,
jwksEndpoint: env.AUTH_OIDC_JWKS_ENDPOINT!, jwksEndpoint: p.jwksEndpoint,
clientId: env.AUTH_OIDC_CLIENT!, clientId: p.client,
clientSecret: env.AUTH_OIDC_SECRET!, clientSecret: p.secret,
scopes: env.AUTH_OIDC_SCOPES?.split(" ") ?? ["openid", "profile", "email"], scopes: p.scopes?.split(" ") ?? ["openid", "profile", "email"],
pkce: env.AUTH_OIDC_PKCE === "true", pkce: p.pkce,
mapping: { mapping: {
extraFields: { extraFields: {
groups: "groups" groups: "groups",
}
}
}, },
providerId: env.AUTH_OIDC_ID!, },
domain: env.AUTH_OIDC_HOST!, },
providerId: p.id,
domain: p.host,
//@ts-ignore //@ts-ignore
issuer: env.AUTH_OIDC_ISSUER_URL! issuer: p.issuerUrl,
}], })),
provisionUser: async ({ user: usr, userInfo }) => { provisionUser: async ({ user: usr, userInfo, provider }) => {
const allowedGroup = env.ALLOWED_GROUP; const providerId = provider.providerId;
const oidcProvider = oidcProviders.find((p) => p.id === providerId);
const allowedGroup = oidcProvider?.allowedGroup || env.ALLOWED_GROUP;
const roleMapStr = oidcProvider?.roleMap;
if (!allowedGroup) return; const rawGroups = userInfo?.groups || userInfo?.roles || [];
const userGroups: string[] = Array.isArray(rawGroups)
? rawGroups
: [rawGroups];
const rawGroups = (userInfo as any).groups || (userInfo as any).roles || []; let roleToAssign: string | undefined;
const userGroups: string[] = Array.isArray(rawGroups) ? rawGroups : [rawGroups]; if (roleMapStr) {
const mappings = roleMapStr.split(",").map((m) => m.split(":"));
for (const [group, role] of mappings) {
if (userGroups.includes(group.trim())) {
roleToAssign = role.trim();
break;
}
}
}
if (!roleToAssign && allowedGroup) {
const hasAccess = userGroups.includes(allowedGroup); const hasAccess = userGroups.includes(allowedGroup);
if (!hasAccess) { if (hasAccess) {
throw new Error("Access Denied"); const userCount = (
await db.select({ count: count() }).from(drizzleDb.schemas.user)
)[0].count;
const isSuperadmin = userCount === 0;
roleToAssign =
allowedGroup.includes("admin") ||
allowedGroup.includes("superadmin")
? isSuperadmin
? "superadmin"
: "admin"
: "pending";
}
} }
const userCount = (await db.select({ count: count() }).from(drizzleDb.schemas.user))[0].count; if (!roleToAssign && oidcProvider?.defaultRole) {
const isSuperadmin = userCount === 0 ? "superadmin" : undefined; roleToAssign = oidcProvider.defaultRole;
}
const roleToAssign = allowedGroup.includes('admin') || allowedGroup.includes('superadmin') ? if (!roleToAssign) {
isSuperadmin ? 'superadmin' : "admin" : 'pending'; console.warn(
`Access Denied for user ${usr.email}: No matching group/role found in ${providerId} config.`,
);
throw new APIError("FORBIDDEN", {
message: `Access Denied: No matching roles found (${roleToAssign})`,
});
}
const userCount = (
await db.select({ count: count() }).from(drizzleDb.schemas.user)
)[0].count;
if (
userCount === 0 &&
(roleToAssign === "admin" || roleToAssign === "superadmin")
) {
roleToAssign = "superadmin";
}
const existingUser = await db.query.user.findFirst({ const existingUser = await db.query.user.findFirst({
where: eq(drizzleDb.schemas.user.email, usr.email) where: eq(drizzleDb.schemas.user.email, usr.email),
}); });
if (existingUser) { if (existingUser) {
await db.update(drizzleDb.schemas.user) await db
.update(drizzleDb.schemas.user)
.set({ role: roleToAssign, emailVerified: true }) .set({ role: roleToAssign, emailVerified: true })
.where(eq(drizzleDb.schemas.user.id, existingUser.id)); .where(eq(drizzleDb.schemas.user.id, existingUser.id));
} else {
return {
...usr,
role: roleToAssign,
emailVerified: true,
};
} }
}, },
}), }),
...(env.AUTH_PASSKEY_ENABLED === "true" ? [passkey({ ...(env.AUTH_PASSKEY_ENABLED === "true"
? [
passkey({
rpName: env.PROJECT_NAME || "Portabase", rpName: env.PROJECT_NAME || "Portabase",
rpID: env.PROJECT_URL ? new URL(env.PROJECT_URL).hostname : "localhost" rpID: env.PROJECT_URL
})] : []), ? new URL(env.PROJECT_URL).hostname
: "localhost",
}),
]
: []),
openAPI(), openAPI(),
nextCookies(), nextCookies(),
twoFactor(), twoFactor(),
@@ -214,12 +306,40 @@ export const auth = betterAuth({
}, },
}, },
databaseHooks: { databaseHooks: {
account: {
create: {
before: async (account) => {
const provider = SUPPORTED_PROVIDERS.find(
(p) => p.id === account.providerId,
);
if (provider && provider.allowLinking === false) {
throw new APIError("FORBIDDEN", {
message: "Linking is disabled for this provider.",
});
}
},
},
delete: {
before: async (account) => {
const provider = SUPPORTED_PROVIDERS.find(
(p) => p.id === account.providerId,
);
if (provider && provider.allowUnlinking === false) {
throw new APIError("FORBIDDEN", {
message: "Unlinking is disabled for this provider.",
});
}
},
},
},
user: { user: {
update: { update: {
async before(user, context) { async before(user, context) {
if (env.AUTH_EMAIL_PASSWORD_ENABLED !== "true") { if (env.AUTH_EMAIL_PASSWORD_ENABLED !== "true") {
if (user.password || user.lastChangedPasswordAt) { if (user.password || user.lastChangedPasswordAt) {
throw new Error("Password updates are disabled"); throw new APIError("FORBIDDEN", {
message: "Password updates are disabled",
});
} }
} }
return { return {
@@ -229,10 +349,14 @@ export const auth = betterAuth({
}, },
create: { create: {
async before(user, context) { async before(user, context) {
const userCount = (await db.select({count: count()}).from(drizzleDb.schemas.user))[0].count; const userCount = (
await db.select({ count: count() }).from(drizzleDb.schemas.user)
)[0].count;
if (env.AUTH_SIGNUP_ENABLED !== "true" && userCount > 0) { if (env.AUTH_SIGNUP_ENABLED !== "true" && userCount > 0) {
throw new Error("Sign up is disabled"); throw new APIError("FORBIDDEN", {
message: "Sign up is disabled",
});
} }
const role = userCount === 0 ? "superadmin" : "pending"; const role = userCount === 0 ? "superadmin" : "pending";
@@ -245,10 +369,11 @@ export const auth = betterAuth({
}; };
}, },
async after(user, context) { async after(user, context) {
const userCount = (await db.select({count: count()}).from(drizzleDb.schemas.user))[0].count; const userCount = (
await db.select({ count: count() }).from(drizzleDb.schemas.user)
)[0].count;
const role = userCount === 0 ? "owner" : "admin"; const role = userCount === 0 ? "owner" : "admin";
const defaultOrgSlug = "default"; const defaultOrgSlug = "default";
const defaultOrg = await db.query.organization.findFirst({ const defaultOrg = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.slug, defaultOrgSlug), where: eq(drizzleDb.schemas.organization.slug, defaultOrgSlug),
@@ -261,7 +386,9 @@ export const auth = betterAuth({
role: role, role: role,
}); });
} else { } else {
console.warn("Default organization not found. Cannot assign member."); console.warn(
"Default organization not found. Cannot assign member.",
);
} }
}, },
}, },
@@ -269,17 +396,27 @@ export const auth = betterAuth({
session: { session: {
create: { create: {
before: async (session, context) => { before: async (session, context) => {
const userId = session.userId; const userId = session.userId;
let memberships = await db.query.member.findMany({ const memberships = await db.query.member.findMany({
where: eq(drizzleDb.schemas.member.userId, userId), where: eq(drizzleDb.schemas.member.userId, userId),
}); });
const url =
context?.request?.url || context?.headers?.get("referer") || "";
let providerId: string;
if (url.includes("/sso/callback")) {
const urlObj = new URL(url, "http://localhost");
providerId = urlObj.searchParams.get("providerId") || "sso";
console.log(`Found provider: ${providerId}`);
}
return { return {
data: { data: {
activeOrganizationId: memberships[0].organizationId, activeOrganizationId: memberships[0].organizationId,
providerId: providerId,
}, },
}; };
}, },
@@ -310,7 +447,6 @@ export const auth = betterAuth({
// } // }
// }, // },
after: async (session) => { after: async (session) => {
console.log("session", session); console.log("session", session);
const user = await db.query.user.findFirst({ const user = await db.query.user.findFirst({
@@ -319,15 +455,20 @@ export const auth = betterAuth({
if (!user) return; if (!user) return;
const createdAtDiff = new Date(session.createdAt).getTime() - new Date(user.createdAt).getTime(); const createdAtDiff =
new Date(session.createdAt).getTime() -
new Date(user.createdAt).getTime();
if (createdAtDiff < 5000) { if (createdAtDiff < 5000) {
console.log(`Skipping new login email for freshly created user ${user.email}`); console.log(
`Skipping new login email for freshly created user ${user.email}`,
);
return; return;
} }
const lastDiff = user.lastConnectedAt const lastDiff = user.lastConnectedAt
? new Date(session.createdAt).getTime() - new Date(user.lastConnectedAt).getTime() ? new Date(session.createdAt).getTime() -
new Date(user.lastConnectedAt).getTime()
: Infinity; : Infinity;
if (lastDiff < 30000) return; if (lastDiff < 30000) return;
@@ -346,7 +487,7 @@ export const auth = betterAuth({
browser: deviceInfo.browser, browser: deviceInfo.browser,
ipAddress: session.ipAddress!, ipAddress: session.ipAddress!,
}), }),
{} {},
), ),
}); });
@@ -356,7 +497,6 @@ export const auth = betterAuth({
}, },
}, },
}, },
}, },
session: { session: {
additionalFields: { additionalFields: {
@@ -420,7 +560,12 @@ export const signInUser = async (email: string, password: string) => {
return user; return user;
};*/ };*/
export const createUser = async (name: string, email: string, password: string, role: "user" | "pending" | "admin" | "superadmin" = "pending") => { export const createUser = async (
name: string,
email: string,
password: string,
role: "user" | "pending" | "admin" | "superadmin" = "pending",
) => {
return await auth.api.createUser({ return await auth.api.createUser({
headers: await headers(), headers: await headers(),
body: { body: {
@@ -453,8 +598,7 @@ export const revokeSession = async (e: string) => {
headers: await headers(), headers: await headers(),
}); });
return status; return status;
} catch (e) { } catch (e) {}
}
}; };
export const getAccounts = async () => { export const getAccounts = async () => {
@@ -474,8 +618,7 @@ export const unlinkAccount = async (provider: string, account: string) => {
}); });
return status; return status;
} catch (e) { } catch (e) {}
}
}; };
export const getOrganization = async ({ export const getOrganization = async ({
@@ -526,9 +669,9 @@ export const revokePasskey = async (e: string) => {
export const listOrganizations = async (): Promise<Organization[] | null> => { export const listOrganizations = async (): Promise<Organization[] | null> => {
try { try {
return await auth.api.listOrganizations({ return (await auth.api.listOrganizations({
headers: await headers(), headers: await headers(),
}) as Organization[]; })) as Organization[];
} catch (e) { } catch (e) {
return null; return null;
} }
@@ -560,7 +703,8 @@ export const createOrganization = async (name: string, slug: string) => {
}, },
}); });
} catch (e: any) { } catch (e: any) {
const errorMessage = e?.response?.data?.message || e?.message || "Unknown auth error"; const errorMessage =
e?.response?.data?.message || e?.message || "Unknown auth error";
const status = e?.response?.status || 500; const status = e?.response?.status || 500;
console.error("Auth API createOrganization error:", { console.error("Auth API createOrganization error:", {
@@ -587,7 +731,8 @@ export const deleteOrganization = async (organizationId: string) => {
headers: await headers(), headers: await headers(),
}); });
} catch (e: any) { } catch (e: any) {
const errorMessage = e?.response?.data?.message || e?.message || "Unknown auth error"; const errorMessage =
e?.response?.data?.message || e?.message || "Unknown auth error";
const status = e?.response?.status || 500; const status = e?.response?.status || 500;
console.error("Auth API deleteOrganization error:", { console.error("Auth API deleteOrganization error:", {
@@ -605,7 +750,6 @@ export const deleteOrganization = async (organizationId: string) => {
} }
}; };
export const checkSlugOrganization = async (slug: string) => { export const checkSlugOrganization = async (slug: string) => {
try { try {
const { status } = await auth.api.checkOrganizationSlug({ const { status } = await auth.api.checkOrganizationSlug({
@@ -616,8 +760,7 @@ export const checkSlugOrganization = async (slug: string) => {
}); });
return status; return status;
} catch { } catch {}
}
}; };
export const getActiveMember = async () => { export const getActiveMember = async () => {
@@ -640,6 +783,5 @@ export const setActiveOrganization = async (slug: string) => {
organizationSlug: slug, organizationSlug: slug,
}, },
}); });
} catch { } catch {}
}
}; };
+27 -14
View File
@@ -1,4 +1,5 @@
import { env } from "@/env.mjs"; import { env } from "@/env.mjs";
import { getOidcProviders } from "./oidc";
export interface AuthProviderConfig { export interface AuthProviderConfig {
id: string; id: string;
@@ -9,8 +10,12 @@ export interface AuthProviderConfig {
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",
@@ -20,7 +25,9 @@ export const SUPPORTED_PROVIDERS: AuthProviderConfig[] = [
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", id: "google",
@@ -29,7 +36,9 @@ export const SUPPORTED_PROVIDERS: AuthProviderConfig[] = [
icon: "logos:google-icon", icon: "logos:google-icon",
title: "Google", title: "Google",
description: "Sign in with your Google account.", description: "Sign in with your Google account.",
type: "social" type: "social",
allowLinking: true,
allowUnlinking: true,
}, },
{ {
id: "github", id: "github",
@@ -38,18 +47,22 @@ export const SUPPORTED_PROVIDERS: AuthProviderConfig[] = [
icon: "logos:github-icon", icon: "logos:github-icon",
title: "GitHub", title: "GitHub",
description: "Sign in with your GitHub account.", description: "Sign in with your GitHub account.",
type: "social" type: "social",
allowLinking: true,
allowUnlinking: true,
}, },
{ ...oidcProviders.map((p) => ({
id: env.AUTH_OIDC_ID || "oidc", id: p.id,
isActive: !!env.AUTH_OIDC_CLIENT, isActive: true,
name: env.AUTH_OIDC_TITLE || "SSO", name: p.title,
icon: env.AUTH_OIDC_ICON || "lucide:building", icon: p.icon,
title: env.AUTH_OIDC_TITLE || "SSO", title: p.title,
description: env.AUTH_OIDC_DESC || "Sign in with your SSO account.", description: p.description,
isManual: true, isManual: true,
type: "sso" type: "sso" as const,
}, allowLinking: p.allowLinking,
allowUnlinking: p.allowUnlinking,
})),
{ {
id: "passkey", id: "passkey",
isActive: env.AUTH_PASSKEY_ENABLED === "true", isActive: env.AUTH_PASSKEY_ENABLED === "true",
@@ -58,6 +71,6 @@ export const SUPPORTED_PROVIDERS: AuthProviderConfig[] = [
title: "Passkey", title: "Passkey",
description: "Sign in with your passkey.", description: "Sign in with your passkey.",
isManual: false, isManual: false,
type: "passkey" 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