mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
fix
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
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 { ProfileGeneral } from "../../profile/profile-general";
|
||||
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")!}
|
||||
isPasswordEnabled={providers.some((p) => p.id === "credential")}
|
||||
isPasskeyEnabled={providers.some((p) => p.id === "passkey")}
|
||||
providers={providers}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
+287
-229
@@ -1,253 +1,311 @@
|
||||
"use client"
|
||||
import {Backup, BackupWith, Restoration} from "@/db/schema/07_database";
|
||||
import {Swiper, SwiperSlide} from "swiper/react";
|
||||
"use client";
|
||||
import { Backup, BackupWith, Restoration } from "@/db/schema/07_database";
|
||||
import { Swiper, SwiperSlide } from "swiper/react";
|
||||
//@ts-ignore
|
||||
import "swiper/css";
|
||||
import "swiper/css/pagination";
|
||||
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 { Pagination, Mousewheel } from "swiper/modules";
|
||||
import {
|
||||
BackupActionsSchema,
|
||||
BackupActionsType
|
||||
DatabaseActionKind,
|
||||
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";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {useMutation, useQueryClient} from "@tanstack/react-query";
|
||||
import {BackupStorageWith} from "@/db/schema/14_storage-backup";
|
||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
||||
import {getChannelIcon} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
import {getStatusColor, getStatusIcon} from "@/components/wrappers/dashboard/admin/notifications/logs/columns";
|
||||
import {useRouter} from "next/navigation";
|
||||
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { BackupStorageWith } from "@/db/schema/14_storage-backup";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { getChannelIcon } from "@/components/wrappers/dashboard/admin/channels/helpers/common";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
createRestorationBackupAction, deleteBackupAction, deleteBackupStorageAction,
|
||||
downloadBackupAction
|
||||
getStatusColor,
|
||||
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";
|
||||
import {toast} from "sonner";
|
||||
import {SafeActionResult} from "next-safe-action";
|
||||
import {ServerActionResult} from "@/types/action-type";
|
||||
import {ZodString} from "zod";
|
||||
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert";
|
||||
import {AlertCircleIcon} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { SafeActionResult } from "next-safe-action";
|
||||
import { ServerActionResult } from "@/types/action-type";
|
||||
import { ZodString } from "zod";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { AlertCircleIcon } from "lucide-react";
|
||||
|
||||
type BackupActionsFormProps = {
|
||||
backup: BackupWith;
|
||||
action: DatabaseActionKind;
|
||||
}
|
||||
backup: BackupWith;
|
||||
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 {closeModal} = useBackupModal();
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const form = useZodForm({
|
||||
schema: BackupActionsSchema,
|
||||
});
|
||||
|
||||
const form = useZodForm({
|
||||
schema: BackupActionsSchema,
|
||||
});
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: BackupActionsType) => {
|
||||
let result:
|
||||
| SafeActionResult<
|
||||
string,
|
||||
ZodString,
|
||||
readonly [],
|
||||
{
|
||||
_errors?: string[] | undefined;
|
||||
},
|
||||
readonly [],
|
||||
ServerActionResult<string | Restoration | Backup>,
|
||||
object
|
||||
>
|
||||
| undefined;
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: BackupActionsType) => {
|
||||
if (action === "download") {
|
||||
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 [], {
|
||||
_errors?: string[] | undefined;
|
||||
}, readonly [], ServerActionResult<string | Restoration | Backup>, object> | undefined
|
||||
const inner = result?.data;
|
||||
|
||||
if (action === "download") {
|
||||
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,
|
||||
})
|
||||
}
|
||||
if (inner?.success) {
|
||||
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 inner = result?.data;
|
||||
const mutationDeleteEntireBackup = useMutation({
|
||||
mutationFn: async () => {
|
||||
const result = await deleteBackupAction({
|
||||
databaseId: backup.databaseId,
|
||||
backupId: backup.id,
|
||||
});
|
||||
|
||||
if (inner?.success) {
|
||||
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 inner = result?.data;
|
||||
|
||||
const mutationDeleteEntireBackup = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (inner?.success) {
|
||||
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({
|
||||
databaseId: backup.databaseId,
|
||||
backupId: backup.id,
|
||||
})
|
||||
|
||||
const inner = result?.data;
|
||||
|
||||
if (inner?.success) {
|
||||
toast.success(inner.actionSuccess?.message);
|
||||
queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]});
|
||||
router.refresh();
|
||||
closeModal()
|
||||
} else {
|
||||
toast.error(inner?.actionError?.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4 mb-1"
|
||||
onSubmit={async (values) => {
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
|
||||
{filteredBackupStorages.length > 0 ?
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="backupStorageId"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Choose a storage backup</FormLabel>
|
||||
<FormControl>
|
||||
|
||||
<div style={{height: "250px"}}>
|
||||
<Swiper
|
||||
direction="vertical"
|
||||
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
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4 mb-1"
|
||||
onSubmit={async (values) => {
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
{filteredBackupStorages.length > 0 ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="backupStorageId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Choose a storage backup</FormLabel>
|
||||
<FormControl>
|
||||
<div style={{ height: "250px" }}>
|
||||
<Swiper
|
||||
direction="vertical"
|
||||
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-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" : ""}`}
|
||||
>
|
||||
<div
|
||||
className={`mt-0.5 h-4 w-4 shrink-0 rounded-full border ${
|
||||
field.value === storage.id ? "border-foreground" : "border-muted-foreground"
|
||||
} flex items-center justify-center`}
|
||||
>
|
||||
{field.value === storage.id &&
|
||||
<div className="h-2 w-2 rounded-full bg-foreground"/>}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<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 gap-2 min-w-0">
|
||||
<div className="shrink-0">
|
||||
{getChannelIcon(storage.storageChannel?.provider || "")}
|
||||
</div>
|
||||
<h3 className="font-medium text-foreground truncate">
|
||||
{storage.storageChannel?.name}
|
||||
</h3>
|
||||
<Badge variant="secondary"
|
||||
className="text-xs font-mono shrink-0">
|
||||
{storage.storageChannel?.provider}
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge variant="outline"
|
||||
className={`gap-1.5 shrink-0 ${getStatusColor(storage.status)}`}>
|
||||
{getStatusIcon(storage.status === "success")}
|
||||
<span
|
||||
className="capitalize">{storage.status.toUpperCase()}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</SwiperSlide>
|
||||
)) ?? <p>No storages available</p>}
|
||||
</Swiper>
|
||||
>
|
||||
<div
|
||||
className={`mt-0.5 h-4 w-4 shrink-0 rounded-full border ${
|
||||
field.value === storage.id
|
||||
? "border-foreground"
|
||||
: "border-muted-foreground"
|
||||
} flex items-center justify-center`}
|
||||
>
|
||||
{field.value === storage.id && (
|
||||
<div className="h-2 w-2 rounded-full bg-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between gap-2 min-w-0 w-full">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div className="shrink-0">
|
||||
{getChannelIcon(
|
||||
storage.storageChannel?.provider ||
|
||||
"",
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-medium text-foreground truncate min-w-0">
|
||||
{storage.storageChannel?.name}
|
||||
</h3>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs font-mono shrink-0"
|
||||
>
|
||||
{storage.storageChannel?.provider}
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`gap-1.5 shrink-0 ${getStatusColor(storage.status)}`}
|
||||
>
|
||||
{getStatusIcon(
|
||||
storage.status === "success",
|
||||
)}
|
||||
<span className="capitalize">
|
||||
{storage.status.toUpperCase()}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
|
||||
:
|
||||
<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">
|
||||
{action === "delete" && (
|
||||
<ButtonWithLoading
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => mutationDeleteEntireBackup.mutateAsync()}
|
||||
isPending={mutationDeleteEntireBackup.isPending}
|
||||
disabled={mutationDeleteEntireBackup.isPending}
|
||||
>
|
||||
Delete entire backup
|
||||
</ButtonWithLoading>
|
||||
)}
|
||||
|
||||
<div className="flex flex-row items-center gap-x-4 w-full">
|
||||
{action === "delete" && (
|
||||
<ButtonWithLoading
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => mutationDeleteEntireBackup.mutateAsync()}
|
||||
isPending={mutationDeleteEntireBackup.isPending}
|
||||
disabled={mutationDeleteEntireBackup.isPending}
|
||||
>
|
||||
Delete entire backup
|
||||
</ButtonWithLoading>
|
||||
)}
|
||||
|
||||
{filteredBackupStorages.length > 0 && (
|
||||
<ButtonWithLoading
|
||||
type="submit"
|
||||
isPending={mutation.isPending}
|
||||
disabled={mutation.isPending}
|
||||
className="ml-auto"
|
||||
>
|
||||
Confirm
|
||||
</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 { Icon } from "@iconify/react";
|
||||
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";
|
||||
|
||||
interface ProfileProviderProps {
|
||||
@@ -122,16 +122,16 @@ export function ProfileProviders({ accounts, providers }: ProfileProviderProps)
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => unlinkAccount(provider.id)}
|
||||
disabled={!canUnlink || isLoading || provider.isManual}
|
||||
className={!canUnlink ? "opacity-50 cursor-not-allowed" : ""}
|
||||
disabled={!canUnlink || isLoading || provider.isManual || provider.allowUnlinking === false}
|
||||
className={!canUnlink || provider.allowUnlinking === false ? "opacity-50 cursor-not-allowed" : ""}
|
||||
>
|
||||
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : "Unlink"}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{!canUnlink && (
|
||||
{(!canUnlink || provider.allowUnlinking === false) && (
|
||||
<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>
|
||||
)}
|
||||
</Tooltip>
|
||||
@@ -141,7 +141,7 @@ export function ProfileProviders({ accounts, providers }: ProfileProviderProps)
|
||||
{provider.id === "credential" ? (
|
||||
<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"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -4,10 +4,22 @@ import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
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 { 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 { ResetPasswordProfileProviderModal } from "./modal/reset-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 { timeAgo } from "@/utils/date-formatting";
|
||||
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 { Label } from "@/components/ui/label";
|
||||
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 {
|
||||
user: User;
|
||||
sessions: Session[];
|
||||
credentialAccount: Account;
|
||||
currentSession: Session;
|
||||
isPasswordEnabled?: boolean;
|
||||
isPasskeyEnabled?: boolean;
|
||||
user: User;
|
||||
sessions: Session[];
|
||||
credentialAccount: Account;
|
||||
currentSession: Session;
|
||||
isPasswordEnabled?: boolean;
|
||||
isPasskeyEnabled?: boolean;
|
||||
providers: AuthProviderConfig[];
|
||||
}
|
||||
|
||||
export function ProfileSecurity({ user, sessions, credentialAccount, currentSession, isPasswordEnabled = false, isPasskeyEnabled = false }: ProfileSecurityProps) {
|
||||
const router = useRouter();
|
||||
export function ProfileSecurity({
|
||||
user,
|
||||
sessions,
|
||||
credentialAccount,
|
||||
currentSession,
|
||||
isPasswordEnabled = false,
|
||||
isPasskeyEnabled = false,
|
||||
providers,
|
||||
}: ProfileSecurityProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const [isBackupCodesDialogOpen, setIsBackupCodesDialogOpen] = useState(false);
|
||||
const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false);
|
||||
const [isSetup2FADialogOpen, setIsSetup2FADialogOpen] = useState(false);
|
||||
const [isDisable2FADialogOpen, setIsDisable2FADialogOpen] = useState(false);
|
||||
const [isAddPasskeyOpen, setIsAddPasskeyOpen] = useState(false);
|
||||
const [passkeyName, setPasskeyName] = useState("");
|
||||
const [isBackupCodesDialogOpen, setIsBackupCodesDialogOpen] = useState(false);
|
||||
const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false);
|
||||
const [isSetup2FADialogOpen, setIsSetup2FADialogOpen] = useState(false);
|
||||
const [isDisable2FADialogOpen, setIsDisable2FADialogOpen] = useState(false);
|
||||
const [isAddPasskeyOpen, setIsAddPasskeyOpen] = useState(false);
|
||||
const [passkeyName, setPasskeyName] = useState("");
|
||||
|
||||
const { mutate: revokeSession, isPending: isRevoking } = useMutation({
|
||||
mutationFn: async (token: string) => {
|
||||
const result = await revokeSessionAction({ token });
|
||||
const inner = result?.data;
|
||||
if (inner?.success) {
|
||||
toast.success("Session successfully revoked");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error("An error occurred while revoking session");
|
||||
}
|
||||
},
|
||||
});
|
||||
const { mutate: revokeSession, isPending: isRevoking } = useMutation({
|
||||
mutationFn: async (token: string) => {
|
||||
const result = await revokeSessionAction({ token });
|
||||
const inner = result?.data;
|
||||
if (inner?.success) {
|
||||
toast.success("Session successfully revoked");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error("An error occurred while revoking session");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: revokeOthers, isPending: isRevokingOthers } = useMutation({
|
||||
mutationFn: async () => {
|
||||
const result = await revokeAllSessionsAction();
|
||||
const inner = result?.data;
|
||||
if (inner?.success) {
|
||||
toast.success("Revoking all sessions successfully done.");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error("An error occurred while revoking all sessions");
|
||||
}
|
||||
},
|
||||
});
|
||||
const { mutate: revokeOthers, isPending: isRevokingOthers } = useMutation({
|
||||
mutationFn: async () => {
|
||||
const result = await revokeAllSessionsAction();
|
||||
const inner = result?.data;
|
||||
if (inner?.success) {
|
||||
toast.success("Revoking all sessions successfully done.");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error("An error occurred while revoking all sessions");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
data: passkeys,
|
||||
isLoading: isLoadingPasskeys,
|
||||
refetch: refetchPasskeys,
|
||||
} = useQuery({
|
||||
queryKey: ["passkeys"],
|
||||
queryFn: async () => {
|
||||
const result = await getPasskeysAction();
|
||||
if (result?.data?.success) {
|
||||
return result.data.value;
|
||||
}
|
||||
throw new Error("Failed to fetch passkeys");
|
||||
},
|
||||
});
|
||||
const {
|
||||
data: passkeys,
|
||||
isLoading: isLoadingPasskeys,
|
||||
refetch: refetchPasskeys,
|
||||
} = useQuery({
|
||||
queryKey: ["passkeys"],
|
||||
queryFn: async () => {
|
||||
const result = await getPasskeysAction();
|
||||
if (result?.data?.success) {
|
||||
return result.data.value;
|
||||
}
|
||||
throw new Error("Failed to fetch passkeys");
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: revokePasskey, isPending: isRevokingPasskey } = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const result = await revokePasskeyAction({ id });
|
||||
if (!result?.data?.success) {
|
||||
throw new Error("Failed to revoke passkey");
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Passkey revoked successfully");
|
||||
refetchPasskeys();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to revoke passkey");
|
||||
},
|
||||
});
|
||||
const { mutate: revokePasskey, isPending: isRevokingPasskey } = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const result = await revokePasskeyAction({ id });
|
||||
if (!result?.data?.success) {
|
||||
throw new Error("Failed to revoke passkey");
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Passkey revoked successfully");
|
||||
refetchPasskeys();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to revoke passkey");
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: addPasskey, isPending: isAddingPasskey } = useMutation({
|
||||
mutationFn: async () => {
|
||||
const result = await authClient.passkey.addPasskey({
|
||||
name: passkeyName || "My Passkey",
|
||||
});
|
||||
if (result?.error) {
|
||||
throw result.error;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Passkey added successfully");
|
||||
setIsAddPasskeyOpen(false);
|
||||
setPasskeyName("");
|
||||
refetchPasskeys();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.message || "Failed to add passkey");
|
||||
},
|
||||
});
|
||||
const { mutate: addPasskey, isPending: isAddingPasskey } = useMutation({
|
||||
mutationFn: async () => {
|
||||
const result = await authClient.passkey.addPasskey({
|
||||
name: passkeyName || "My Passkey",
|
||||
});
|
||||
if (result?.error) {
|
||||
throw result.error;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Passkey added successfully");
|
||||
setIsAddPasskeyOpen(false);
|
||||
setPasskeyName("");
|
||||
refetchPasskeys();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.message || "Failed to add passkey");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in-50 duration-300">
|
||||
<div className="mb-6 space-y-1">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Security Settings</h2>
|
||||
<p className="text-sm text-muted-foreground">Manage your password, two-factor authentication and sessions.</p>
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in-50 duration-300">
|
||||
<div className="mb-6 space-y-1">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
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 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>
|
||||
|
||||
{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>
|
||||
{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 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({
|
||||
session,
|
||||
onRevoke,
|
||||
isRevoking,
|
||||
currentSession,
|
||||
session,
|
||||
onRevoke,
|
||||
isRevoking,
|
||||
currentSession,
|
||||
providers,
|
||||
}: {
|
||||
session: Session;
|
||||
onRevoke: (token: string) => void;
|
||||
isRevoking: boolean;
|
||||
currentSession: Session;
|
||||
session: Session;
|
||||
onRevoke: (token: string) => void;
|
||||
isRevoking: boolean;
|
||||
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 (
|
||||
<div className="flex items-center justify-between p-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">
|
||||
<deviceInfo.Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm font-medium flex items-center gap-2">
|
||||
{deviceInfo.os} <span className="text-muted-foreground font-normal">• {deviceInfo.browser}</span>
|
||||
{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>
|
||||
|
||||
{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>
|
||||
return (
|
||||
<div className="flex items-center justify-between p-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 relative">
|
||||
<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 className="space-y-0.5">
|
||||
<div className="text-sm font-medium flex items-center gap-2">
|
||||
{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 && (
|
||||
<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>
|
||||
|
||||
{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 }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between p-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">
|
||||
<Fingerprint className="w-5 h-5" />
|
||||
</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>
|
||||
function PasskeyRow({
|
||||
passkey,
|
||||
onRevoke,
|
||||
isRevoking,
|
||||
}: {
|
||||
passkey: any;
|
||||
onRevoke: (id: string) => void;
|
||||
isRevoking: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between p-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">
|
||||
<Fingerprint className="w-5 h-5" />
|
||||
</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,
|
||||
"tag": "0035_windy_shockwave",
|
||||
"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")
|
||||
.notNull()
|
||||
.references(() => user.id, {onDelete: "cascade"}),
|
||||
providerId: text("provider_id"),
|
||||
impersonatedBy: text("impersonated_by"), //id or name ????
|
||||
activeOrganizationId: text("active_organization_id"),
|
||||
...timestamps
|
||||
|
||||
});
|
||||
|
||||
export const account = pgTable("account", {
|
||||
|
||||
+86
-80
@@ -1,107 +1,113 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import path from "path";
|
||||
import { z } from "zod";
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
import path from "path";
|
||||
|
||||
const { version } = packageJson;
|
||||
|
||||
export const env = createEnv({
|
||||
server: {
|
||||
NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(),
|
||||
server: {
|
||||
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_DESCRIPTION: z.string().optional(),
|
||||
PROJECT_URL: z.string().regex(/^https?:\/\//, "URL must start with http:// or https://"),
|
||||
PROJECT_SECRET: z.string(),
|
||||
PROJECT_NAME: z.string().optional(),
|
||||
PROJECT_DESCRIPTION: z.string().optional(),
|
||||
PROJECT_URL: z
|
||||
.string()
|
||||
.regex(/^https?:\/\//, "URL must start with http:// or https://"),
|
||||
PROJECT_SECRET: z.string(),
|
||||
|
||||
SMTP_PASSWORD: z.string().optional(),
|
||||
SMTP_FROM: z.string().optional(),
|
||||
SMTP_HOST: z.string().optional(),
|
||||
SMTP_PORT: z.string().optional(),
|
||||
SMTP_USER: z.string().optional(),
|
||||
SMTP_SECURE: z.coerce.boolean().default(true),
|
||||
SMTP_PASSWORD: z.string().optional(),
|
||||
SMTP_FROM: z.string().optional(),
|
||||
SMTP_HOST: z.string().optional(),
|
||||
SMTP_PORT: z.string().optional(),
|
||||
SMTP_USER: z.string().optional(),
|
||||
SMTP_SECURE: z.coerce.boolean().default(true),
|
||||
|
||||
AUTH_GOOGLE_ID: z.string().optional(),
|
||||
AUTH_GOOGLE_SECRET: z.string().optional(),
|
||||
AUTH_GOOGLE_METHOD: z.boolean().default(false),
|
||||
AUTH_GOOGLE_ID: z.string().optional(),
|
||||
AUTH_GOOGLE_SECRET: z.string().optional(),
|
||||
AUTH_GOOGLE_METHOD: z.boolean().default(false),
|
||||
|
||||
AUTH_GITHUB_ID: z.string().optional(),
|
||||
AUTH_GITHUB_SECRET: z.string().optional(),
|
||||
AUTH_GITHUB_ID: 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_TITLE: z.string().optional(),
|
||||
AUTH_OIDC_DESC: z.string().optional(),
|
||||
AUTH_OIDC_ICON: z.string().optional(),
|
||||
AUTH_OIDC_CLIENT: z.string().optional(),
|
||||
AUTH_OIDC_SECRET: z.string().optional(),
|
||||
AUTH_OIDC_ISSUER_URL: z.string().optional(),
|
||||
AUTH_OIDC_HOST: z.string().optional(),
|
||||
AUTH_OIDC_SCOPES: z.string().optional(),
|
||||
AUTH_OIDC_DISCOVERY_ENDPOINT: z.string().optional(),
|
||||
AUTH_OIDC_JWKS_ENDPOINT: z.string().optional(),
|
||||
AUTH_OIDC_PKCE: z.string().optional(),
|
||||
ALLOWED_GROUP: z.string().optional(),
|
||||
AUTH_OIDC_ID: z.string().optional().default("oidc"),
|
||||
AUTH_OIDC_TITLE: z.string().optional(),
|
||||
AUTH_OIDC_DESC: z.string().optional(),
|
||||
AUTH_OIDC_ICON: z.string().optional(),
|
||||
AUTH_OIDC_CLIENT: z.string().optional(),
|
||||
AUTH_OIDC_SECRET: z.string().optional(),
|
||||
AUTH_OIDC_ISSUER_URL: z.string().optional(),
|
||||
AUTH_OIDC_HOST: z.string().optional(),
|
||||
AUTH_OIDC_SCOPES: z.string().optional(),
|
||||
AUTH_OIDC_DISCOVERY_ENDPOINT: z.string().optional(),
|
||||
AUTH_OIDC_JWKS_ENDPOINT: z.string().optional(),
|
||||
AUTH_OIDC_PKCE: z.string().optional(),
|
||||
ALLOWED_GROUP: z.string().optional(),
|
||||
|
||||
AUTH_EMAIL_PASSWORD_ENABLED: z.string().optional().default("true"),
|
||||
AUTH_SIGNUP_ENABLED: z.string().optional().default("true"),
|
||||
AUTH_PASSKEY_ENABLED: z.string().optional().default("true"),
|
||||
AUTH_EMAIL_PASSWORD_ENABLED: z.string().optional().default("true"),
|
||||
AUTH_SIGNUP_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",
|
||||
|
||||
},
|
||||
client: {
|
||||
NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(),
|
||||
},
|
||||
runtimeEnv: {
|
||||
NEXT_PUBLIC_PROJECT_VERSION: version || "Unknown Version",
|
||||
PROJECT_NAME: process.env.PROJECT_NAME,
|
||||
PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION,
|
||||
PROJECT_URL: process.env.PROJECT_URL,
|
||||
PROJECT_SECRET: process.env.PROJECT_SECRET,
|
||||
|
||||
PROJECT_NAME: process.env.PROJECT_NAME,
|
||||
PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION,
|
||||
PROJECT_URL: process.env.PROJECT_URL,
|
||||
PROJECT_SECRET: process.env.PROJECT_SECRET,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
|
||||
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,
|
||||
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,
|
||||
AUTH_GOOGLE_ID: process.env.AUTH_GOOGLE_ID,
|
||||
AUTH_GOOGLE_SECRET: process.env.AUTH_GOOGLE_SECRET,
|
||||
AUTH_GOOGLE_METHOD: process.env.AUTH_GOOGLE_METHOD === "true",
|
||||
|
||||
AUTH_GOOGLE_ID: process.env.AUTH_GOOGLE_ID,
|
||||
AUTH_GOOGLE_SECRET: process.env.AUTH_GOOGLE_SECRET,
|
||||
AUTH_GOOGLE_METHOD: process.env.AUTH_GOOGLE_METHOD === "true",
|
||||
AUTH_GITHUB_ID: process.env.AUTH_GITHUB_ID,
|
||||
AUTH_GITHUB_SECRET: process.env.AUTH_GITHUB_SECRET,
|
||||
|
||||
AUTH_GITHUB_ID: process.env.AUTH_GITHUB_ID,
|
||||
AUTH_GITHUB_SECRET: process.env.AUTH_GITHUB_SECRET,
|
||||
RETENTION_CRON: process.env.RETENTION_CRON,
|
||||
|
||||
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_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_EMAIL_PASSWORD_ENABLED: process.env.AUTH_EMAIL_PASSWORD_ENABLED,
|
||||
AUTH_SIGNUP_ENABLED: process.env.AUTH_SIGNUP_ENABLED,
|
||||
AUTH_PASSKEY_ENABLED: process.env.AUTH_PASSKEY_ENABLED,
|
||||
|
||||
AUTH_EMAIL_PASSWORD_ENABLED: process.env.AUTH_EMAIL_PASSWORD_ENABLED,
|
||||
AUTH_SIGNUP_ENABLED: process.env.AUTH_SIGNUP_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"),
|
||||
},
|
||||
});
|
||||
|
||||
+654
-512
File diff suppressed because it is too large
Load Diff
+70
-57
@@ -1,63 +1,76 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import { getOidcProviders } from "./oidc";
|
||||
|
||||
export interface AuthProviderConfig {
|
||||
id: string;
|
||||
isActive: boolean;
|
||||
name?: string;
|
||||
icon: string;
|
||||
isManual?: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
type: "social" | "sso" | "credential" | "passkey";
|
||||
id: string;
|
||||
isActive: boolean;
|
||||
name?: string;
|
||||
icon: string;
|
||||
isManual?: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
type: "social" | "sso" | "credential" | "passkey";
|
||||
allowLinking?: boolean;
|
||||
allowUnlinking?: boolean;
|
||||
}
|
||||
|
||||
const oidcProviders = getOidcProviders();
|
||||
|
||||
export const SUPPORTED_PROVIDERS: AuthProviderConfig[] = [
|
||||
{
|
||||
id: "credential",
|
||||
isActive: env.AUTH_EMAIL_PASSWORD_ENABLED === "true",
|
||||
name: "Password",
|
||||
icon: "lucide:lock",
|
||||
title: "Password",
|
||||
description: "Standard email and password login.",
|
||||
isManual: true,
|
||||
type: "credential"
|
||||
},
|
||||
{
|
||||
id: "google",
|
||||
isActive: !!env.AUTH_GOOGLE_ID,
|
||||
name: "Google",
|
||||
icon: "logos:google-icon",
|
||||
title: "Google",
|
||||
description: "Sign in with your Google account.",
|
||||
type: "social"
|
||||
},
|
||||
{
|
||||
id: "github",
|
||||
isActive: !!env.AUTH_GITHUB_ID,
|
||||
name: "GitHub",
|
||||
icon: "logos:github-icon",
|
||||
title: "GitHub",
|
||||
description: "Sign in with your GitHub account.",
|
||||
type: "social"
|
||||
},
|
||||
{
|
||||
id: env.AUTH_OIDC_ID || "oidc",
|
||||
isActive: !!env.AUTH_OIDC_CLIENT,
|
||||
name: env.AUTH_OIDC_TITLE || "SSO",
|
||||
icon: env.AUTH_OIDC_ICON || "lucide:building",
|
||||
title: env.AUTH_OIDC_TITLE || "SSO",
|
||||
description: env.AUTH_OIDC_DESC || "Sign in with your SSO account.",
|
||||
isManual: true,
|
||||
type: "sso"
|
||||
},
|
||||
{
|
||||
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"
|
||||
}
|
||||
];
|
||||
{
|
||||
id: "credential",
|
||||
isActive: env.AUTH_EMAIL_PASSWORD_ENABLED === "true",
|
||||
name: "Password",
|
||||
icon: "lucide:lock",
|
||||
title: "Password",
|
||||
description: "Standard email and password login.",
|
||||
isManual: true,
|
||||
type: "credential",
|
||||
allowLinking: true,
|
||||
allowUnlinking: true,
|
||||
},
|
||||
{
|
||||
id: "google",
|
||||
isActive: !!env.AUTH_GOOGLE_ID,
|
||||
name: "Google",
|
||||
icon: "logos:google-icon",
|
||||
title: "Google",
|
||||
description: "Sign in with your Google account.",
|
||||
type: "social",
|
||||
allowLinking: true,
|
||||
allowUnlinking: true,
|
||||
},
|
||||
{
|
||||
id: "github",
|
||||
isActive: !!env.AUTH_GITHUB_ID,
|
||||
name: "GitHub",
|
||||
icon: "logos:github-icon",
|
||||
title: "GitHub",
|
||||
description: "Sign in with your GitHub account.",
|
||||
type: "social",
|
||||
allowLinking: true,
|
||||
allowUnlinking: true,
|
||||
},
|
||||
...oidcProviders.map((p) => ({
|
||||
id: p.id,
|
||||
isActive: true,
|
||||
name: p.title,
|
||||
icon: p.icon,
|
||||
title: p.title,
|
||||
description: p.description,
|
||||
isManual: true,
|
||||
type: "sso" as const,
|
||||
allowLinking: p.allowLinking,
|
||||
allowUnlinking: p.allowUnlinking,
|
||||
})),
|
||||
{
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user