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