mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
fix
This commit is contained in:
@@ -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
@@ -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>
|
||||||
|
|
||||||
|
|||||||
+120
-62
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "session" ADD COLUMN "provider_id" text;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
Reference in New Issue
Block a user